mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 08:42:39 +00:00
Compare commits
13
Commits
1.2.7
..
1.2.5-fix2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f70b44040 | ||
|
|
5d26f1b18a | ||
|
|
80cc39b02d | ||
|
|
11c65ddbd0 | ||
|
|
ad74b57ecc | ||
|
|
e13f79577d | ||
|
|
832b80054f | ||
|
|
09d70996d3 | ||
|
|
303f2bbcf5 | ||
|
|
28ce225aa0 | ||
|
|
683b7f652b | ||
|
|
a181b22071 | ||
|
|
d2f9261bc2 |
No files matched your search
@@ -59,10 +59,6 @@ jobs:
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
|
||||
@@ -65,10 +65,6 @@ jobs:
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
|
||||
@@ -112,10 +112,6 @@ jobs:
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
|
||||
+18
-1
@@ -8,12 +8,29 @@ ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
RUN set -eux; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) cloudflared_arch="amd64" ;; \
|
||||
386) cloudflared_arch="386" ;; \
|
||||
arm64) cloudflared_arch="arm64" ;; \
|
||||
arm) cloudflared_arch="arm" ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflared_arch}" -o /usr/local/bin/cloudflared; \
|
||||
chmod +x /usr/local/bin/cloudflared
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
|
||||
ENV GIN_MODE=release
|
||||
ENV KOMARI_DB_TYPE=sqlite
|
||||
ENV KOMARI_DB_FILE=/app/data/komari.db
|
||||
ENV KOMARI_DB_HOST=localhost
|
||||
ENV KOMARI_DB_PORT=3306
|
||||
ENV KOMARI_DB_USER=root
|
||||
ENV KOMARI_DB_PASS=
|
||||
ENV KOMARI_DB_NAME=komari
|
||||
ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
-498
@@ -1,498 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/pkg/migrations"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
frontendpublic "github.com/komari-monitor/komari/web/public"
|
||||
"github.com/komari-monitor/komari/web/router"
|
||||
"github.com/komari-monitor/komari/web/security"
|
||||
upgradeweb "github.com/komari-monitor/komari/web/update"
|
||||
)
|
||||
|
||||
// cleanupFunc 是一个关闭阶段执行的清理函数。
|
||||
type cleanupFunc struct {
|
||||
name string
|
||||
fn func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// App 显式建模服务端的启动生命周期。
|
||||
//
|
||||
// 过去 RunServer 把目录创建、数据库、metric store、GeoIP、定时任务、通知、OAuth、
|
||||
// Gin 中间件、路由、HTTP 启动与 shutdown 全部混在一个函数里,
|
||||
// 启动顺序只能靠通读整段代码推断,异步初始化过早且吞掉错误,关闭也不完整。
|
||||
//
|
||||
// App 把这些拆成有序阶段:
|
||||
//
|
||||
// Bootstrap 基础设施:目录、数据库、默认管理员、配置快照
|
||||
// InitStores 存储:metric store
|
||||
// InitProviders 外部 provider:OAuth(同步,路由依赖)、GeoIP、消息发送
|
||||
// StartBackground 后台:定时任务
|
||||
// BuildRouter 构建 Gin 引擎与路由
|
||||
// Run 启动 HTTP 服务并阻塞直到收到信号
|
||||
// Shutdown 反序执行已登记的清理函数
|
||||
//
|
||||
// 每个阶段返回错误即可让上层决定是否中止启动;各资源在创建时把对应清理登记到
|
||||
// cleanup 栈,关闭时按后进先出(LIFO)顺序释放。
|
||||
type App struct {
|
||||
settings *config.Settings
|
||||
engine *gin.Engine
|
||||
server *http.Server
|
||||
reload *ReloadManager
|
||||
dbReady bool
|
||||
oauthReady bool
|
||||
|
||||
cleanups []cleanupFunc
|
||||
}
|
||||
|
||||
// NewApp 构造一个空的 App。真正的初始化在各阶段方法中完成。
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
reload: NewReloadManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// addCleanup 登记一个关闭阶段的清理函数(后进先出执行)。
|
||||
func (a *App) addCleanup(name string, fn func(ctx context.Context) error) {
|
||||
a.cleanups = append(a.cleanups, cleanupFunc{name: name, fn: fn})
|
||||
}
|
||||
|
||||
// Bootstrap 初始化基础设施:数据目录、数据库、默认管理员账号、配置快照。
|
||||
func (a *App) Bootstrap() error {
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create theme directory: %w", err)
|
||||
}
|
||||
|
||||
// 注入版本标识,供 dbcore 在检测到版本升级时自动备份 ./data。
|
||||
// 必须在 Initialize() 之前设置。
|
||||
dbcore.SetVersionID(utils.CurrentVersion + "-" + utils.VersionHash)
|
||||
|
||||
// 显式初始化数据库(返回错误而非在 getter 里 log.Fatal)。
|
||||
if err := dbcore.Initialize(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
a.dbReady = true
|
||||
a.addCleanup("database", func(context.Context) error {
|
||||
return dbcore.Close()
|
||||
})
|
||||
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 首次启动创建默认管理员账号。
|
||||
if err := ensureDefaultAdmin(); err != nil {
|
||||
return fmt.Errorf("failed to ensure default admin account: %w", err)
|
||||
}
|
||||
|
||||
conf, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
a.settings = conf
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitStores 初始化独立存储组件(metric store)并执行 metrics 迁移。
|
||||
//
|
||||
// metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃):
|
||||
// 未显式配置时使用 SQLite(./data/metrics.db),否则使用配置的 MySQL/PostgreSQL。
|
||||
// 初始化失败即启动失败,不再静默 fallback 到旧 records 表。
|
||||
//
|
||||
// 初始化成功后先执行需要 metric store 的一次性迁移,再执行启动迁移:当 metrics
|
||||
// 存储后端发生变化(例如从默认 SQLite 切换到 MySQL/PostgreSQL)时,把上一个
|
||||
// metrics 目标库的数据搬运到当前目标。迁移失败同样让启动失败,并打印明确错误。
|
||||
func (a *App) InitStores() error {
|
||||
if err := metricstore.InitializeStore(); err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize metric store: %v", err))
|
||||
return fmt.Errorf("failed to initialize metric store: %w", err)
|
||||
}
|
||||
a.addCleanup("metric-store", func(ctx context.Context) error {
|
||||
return metricstore.CloseStoreContext(ctx)
|
||||
})
|
||||
|
||||
if err := migrations.RunMetricStoreMigrations(migrations.MetricContext{
|
||||
DB: dbcore.GetDBInstance(),
|
||||
Store: metricstore.GetStore(),
|
||||
}); err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Metric store one-shot migrations failed: %v", err))
|
||||
return fmt.Errorf("metric store one-shot migrations failed: %w", err)
|
||||
}
|
||||
|
||||
// 存储后端切换时把上一个 metrics 目标库的数据搬运到当前目标(失败即启动失败)。
|
||||
if err := metricstore.RunStartupMigration(); err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Metrics startup migration failed: %v", err))
|
||||
|
||||
return fmt.Errorf("metrics startup migration failed: %w", err)
|
||||
}
|
||||
metricstore.StartReportBatcher()
|
||||
a.addCleanup("metric-report-batcher", func(ctx context.Context) error {
|
||||
return metricstore.StopReportBatcher(ctx)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitProviders 初始化外部 provider。
|
||||
//
|
||||
// OAuth 必须在 HTTP 服务开始接收请求之前同步完成,否则 oauth.CurrentProvider()
|
||||
// 存在空指针风险;GeoIP 与消息发送允许后台初始化。
|
||||
func (a *App) InitProviders() error {
|
||||
a.initOAuth()
|
||||
|
||||
// GeoIP:可能涉及下载/加载 mmdb,放后台执行避免拖慢启动。
|
||||
go geoip.InitGeoIp()
|
||||
a.addCleanup("geoip", func(context.Context) error {
|
||||
return geoip.Shutdown()
|
||||
})
|
||||
|
||||
// 消息发送 provider。
|
||||
messageSender.Initialize()
|
||||
a.addCleanup("message-sender", func(context.Context) error {
|
||||
return messageSender.Shutdown()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initOAuth initializes the provider once. The restricted upgrade server also
|
||||
// needs OAuth login, so provider setup may happen before the normal provider
|
||||
// stage without registering duplicate cleanup work.
|
||||
func (a *App) initOAuth() {
|
||||
if a.oauthReady {
|
||||
return
|
||||
}
|
||||
if err := oauth.Initialize(); err != nil {
|
||||
// Keep password login available when an OAuth provider is misconfigured.
|
||||
log.Printf("Failed to initialize OAuth provider: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
|
||||
}
|
||||
a.oauthReady = true
|
||||
a.addCleanup("oauth", func(context.Context) error {
|
||||
return oauth.Shutdown()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) LegacyUpgradeRequired() (bool, migrations.LegacyMonitoringSummary, error) {
|
||||
return migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
|
||||
}
|
||||
|
||||
// RunLegacyUpgrade starts a restricted HTTP server on the normal listen
|
||||
// address. It returns only after the migration finishes and the restricted
|
||||
// listener has released the port, or after shutdown/interruption.
|
||||
func (a *App) RunLegacyUpgrade(summary migrations.LegacyMonitoringSummary) (bool, error) {
|
||||
a.initOAuth()
|
||||
controller := upgradeweb.NewController(dbcore.GetDBInstance(), summary)
|
||||
controller.Activate()
|
||||
defer controller.Deactivate()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
|
||||
r.Use(cors.Middleware())
|
||||
r.Use(api.IdentityMiddleware())
|
||||
r.Use(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
controller.Register(r)
|
||||
frontendpublic.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
requestPath := c.Request.URL.Path
|
||||
if strings.HasPrefix(requestPath, "/api") {
|
||||
api.RespondError(c, http.StatusNotFound, "Not found in upgrade mode")
|
||||
return
|
||||
}
|
||||
if c.Request.Method == http.MethodGet && requestPath != upgradeweb.PagePath && filepath.Ext(requestPath) == "" {
|
||||
c.Redirect(http.StatusTemporaryRedirect, upgradeweb.PagePath)
|
||||
return
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
handler(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
a.engine = r
|
||||
a.server = &http.Server{Addr: flags.Listen, Handler: r}
|
||||
serverErr := make(chan error, 1)
|
||||
log.Printf("Legacy monitoring data requires the 1.2.7 upgrade wizard on %s", flags.Listen)
|
||||
go func() {
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
serverErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(quit)
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
return false, fmt.Errorf("listen in upgrade mode: %w", err)
|
||||
case <-quit:
|
||||
return false, a.Shutdown()
|
||||
case <-controller.Done():
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
return false, fmt.Errorf("stop upgrade server: %w", err)
|
||||
}
|
||||
a.server = nil
|
||||
a.engine = nil
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackground 启动后台工作:定时任务。
|
||||
func (a *App) StartBackground() error {
|
||||
registerScheduledWork()
|
||||
a.addCleanup("scheduler", func(context.Context) error {
|
||||
corn.StopAll()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerReloadHandlers 把此前散落各处的 config.Subscribe 统一登记到 reload 管理器。
|
||||
func (a *App) registerReloadHandlers(cors *security.CorsController) {
|
||||
// OAuth provider 切换。
|
||||
a.reload.Register("oauth-provider", func(event config.ConfigEvent) {
|
||||
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
|
||||
if t == "" || t == "none" {
|
||||
t = "github"
|
||||
}
|
||||
oidcProvider, err := database.GetOidcConfigByName(t)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
|
||||
if err := oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition); err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// GeoIP provider 切换。
|
||||
a.reload.Register("geoip-provider", func(event config.ConfigEvent) {
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
})
|
||||
|
||||
// 消息发送方式切换。
|
||||
a.reload.Register("message-sender", func(event config.ConfigEvent) {
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
})
|
||||
|
||||
// CORS 配置热更新。
|
||||
a.reload.Register("cors", func(event config.ConfigEvent) {
|
||||
cors.Update(event)
|
||||
})
|
||||
}
|
||||
|
||||
// BuildRouter 构建 Gin 引擎、中间件与全部路由,并登记热重载处理器。
|
||||
func (a *App) BuildRouter() error {
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
|
||||
r.Use(cors.Middleware())
|
||||
|
||||
r.Use(api.IdentityMiddleware())
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.Register(r)
|
||||
|
||||
// 集中登记并启动热重载订阅。
|
||||
a.registerReloadHandlers(cors)
|
||||
a.reload.Start()
|
||||
|
||||
a.engine = r
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run 启动 HTTP 服务并阻塞直到收到中断信号或服务异常退出。
|
||||
func (a *App) Run() error {
|
||||
a.server = &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: a.engine,
|
||||
}
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
log.Printf("Starting server on %s ...", flags.Listen)
|
||||
go func() {
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
serverErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
a.onFatal(err)
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
case <-quit:
|
||||
return a.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭:先停止接收新请求,再反序执行已登记的清理函数。
|
||||
func (a *App) Shutdown() error {
|
||||
if a.dbReady {
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 先关闭 HTTP 服务,停止接收新请求。
|
||||
if a.server != nil {
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
log.Printf("HTTP server forced to shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 反序释放资源(后进先出)。
|
||||
for i := len(a.cleanups) - 1; i >= 0; i-- {
|
||||
c := a.cleanups[i]
|
||||
if err := c.fn(ctx); err != nil {
|
||||
log.Printf("cleanup %q failed: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// onFatal 处理 HTTP 服务致命错误:尽力释放已登记资源。
|
||||
func (a *App) onFatal(err error) {
|
||||
if a.dbReady {
|
||||
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for i := len(a.cleanups) - 1; i >= 0; i-- {
|
||||
c := a.cleanups[i]
|
||||
if cerr := c.fn(ctx); cerr != nil {
|
||||
log.Printf("cleanup %q failed: %v", c.name, cerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDefaultAdmin 首次启动(无任何用户)时创建默认管理员账号。
|
||||
func ensureDefaultAdmin() error {
|
||||
var count int64
|
||||
dbcore.GetDBInstance().Model(&models.User{}).Count(&count)
|
||||
if count != 0 {
|
||||
return nil
|
||||
}
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerScheduledWork 注册所有定时任务与首启动同步逻辑。
|
||||
func registerScheduledWork() {
|
||||
if err := tasks.ReloadPingSchedule(); err != nil {
|
||||
log.Println("Failed to reload ping schedule:", err)
|
||||
}
|
||||
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
|
||||
log.Println("Failed to reload load notification schedule:", err)
|
||||
}
|
||||
|
||||
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
|
||||
log.Println("Failed to add cleanup scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddContextFunc("metrics:compact", "@every 5m", true, compactMetricStore); err != nil {
|
||||
log.Println("Failed to add metric compact scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("notifier:traffic", "@every 1m", notifier.CheckTraffic); err != nil {
|
||||
log.Println("Failed to add traffic notification task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
|
||||
log.Println("Failed to add expire notification scheduled task:", err)
|
||||
}
|
||||
|
||||
notifier.InitTrafficReportSchedule()
|
||||
}
|
||||
|
||||
const taskResultRetentionDays = 30
|
||||
|
||||
func cleanupScheduledData() {
|
||||
before := time.Now().UTC().Add(-24 * time.Hour * taskResultRetentionDays)
|
||||
if err := tasks.ClearTaskResultsByTimeBefore(before); err != nil {
|
||||
log.Printf("Failed to clean expired task results: %v", err)
|
||||
}
|
||||
|
||||
auditlog.RemoveOldLogs()
|
||||
accounts.RemoveExpiredSessions()
|
||||
}
|
||||
|
||||
func compactMetricStore(ctx context.Context) {
|
||||
compactCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
written, err := metricstore.Compact(compactCtx, time.Now().UTC())
|
||||
if errors.Is(err, metricstore.ErrCompactInProgress) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Failed to compact metric store after writing %d rollup buckets: %v", written, err)
|
||||
return
|
||||
}
|
||||
if written > 0 {
|
||||
log.Printf("Metric store compacted %d rollup buckets", written)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // 保留的兼容参数,当前未使用
|
||||
DatabasePort string // 保留的兼容参数,当前未使用
|
||||
DatabaseUser string // 保留的兼容参数,当前未使用
|
||||
DatabasePass string // 保留的兼容参数,当前未使用
|
||||
DatabaseName string // 保留的兼容参数,当前未使用
|
||||
|
||||
Listen string
|
||||
)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
)
|
||||
|
||||
// reloadHandler 是单个配置热重载处理器。
|
||||
//
|
||||
// name 仅用于日志定位,handler 接收配置变更事件,自行判断关心的 key 是否变化。
|
||||
// 每个 handler 相互独立:一个 panic 不应影响其它 handler。
|
||||
type reloadHandler struct {
|
||||
name string
|
||||
handler func(config.ConfigEvent)
|
||||
}
|
||||
|
||||
// ReloadManager 统一收敛所有配置热重载逻辑。
|
||||
//
|
||||
// 过去 config.Subscribe 分散在 cmd/server.go、cors.go 等多处,导致:
|
||||
// - 谁监听哪些 key 不清晰;
|
||||
// - 某个 handler panic 会连带影响同一订阅回调里的其它逻辑;
|
||||
// - 关闭时无法统一停止。
|
||||
//
|
||||
// ReloadManager 把这些 handler 注册在一处,用单个 config.Subscribe 分发,
|
||||
// 并对每个 handler 做 panic 隔离。
|
||||
type ReloadManager struct {
|
||||
handlers []reloadHandler
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewReloadManager 创建一个空的热重载管理器。
|
||||
func NewReloadManager() *ReloadManager {
|
||||
return &ReloadManager{}
|
||||
}
|
||||
|
||||
// Register 注册一个命名的热重载处理器。必须在 Start 之前调用。
|
||||
func (m *ReloadManager) Register(name string, handler func(config.ConfigEvent)) {
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
m.handlers = append(m.handlers, reloadHandler{name: name, handler: handler})
|
||||
}
|
||||
|
||||
// Start 向 config 订阅一次,之后每个事件会分发给全部已注册 handler。
|
||||
// 重复调用无副作用。
|
||||
func (m *ReloadManager) Start() {
|
||||
if m.started {
|
||||
return
|
||||
}
|
||||
m.started = true
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
for _, h := range m.handlers {
|
||||
m.dispatch(h, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dispatch 执行单个 handler,并隔离 panic,避免一个 handler 影响其它。
|
||||
func (m *ReloadManager) dispatch(h reloadHandler, event config.ConfigEvent) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("config reload handler %q panicked: %v", h.name, r)
|
||||
}
|
||||
}()
|
||||
h.handler(event)
|
||||
}
|
||||
+22
-2
@@ -17,6 +17,17 @@ func GetEnv(key, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// 从环境变量获取默认值
|
||||
var (
|
||||
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "Komari",
|
||||
Short: "Komari is a simple server monitoring tool",
|
||||
@@ -36,6 +47,15 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", "sqlite", "Database type (sqlite)")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "SQLite database file path")
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
|
||||
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
|
||||
_ = RootCmd.PersistentFlags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
+204
-42
@@ -1,9 +1,40 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/web/nezha"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
report_cache "github.com/komari-monitor/komari/web/report"
|
||||
"github.com/komari-monitor/komari/web/router"
|
||||
"github.com/komari-monitor/komari/web/security"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -23,53 +54,184 @@ func init() {
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
// RunServer 按显式的生命周期阶段启动服务端。
|
||||
//
|
||||
// 具体各阶段的职责与顺序见 App(cmd/app.go)。这里只负责串联:
|
||||
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
|
||||
func RunServer() {
|
||||
app := NewApp()
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
_ = app.Shutdown()
|
||||
log.Fatalf("server startup failed at %q: %v", "bootstrap", err)
|
||||
// #region 初始化
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
log.Fatalf("Failed to create theme directory: %v", err)
|
||||
}
|
||||
|
||||
required, summary, err := app.LegacyUpgradeRequired()
|
||||
InitDatabase()
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
log.Fatalf("server startup failed at %q: %v", "detect-1.2.7-upgrade", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
if required {
|
||||
completed, err := app.RunLegacyUpgrade(summary)
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
// oidcInit
|
||||
go oauth.Initialize()
|
||||
|
||||
if conf.NezhaCompatEnabled {
|
||||
go func() {
|
||||
if err := nezha.StartNezhaCompat(conf.NezhaCompatListen); err != nil {
|
||||
log.Printf("Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
|
||||
if t == "" || t == "none" {
|
||||
t = "github"
|
||||
}
|
||||
oidcProvider, err := database.GetOidcConfigByName(t)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
} else {
|
||||
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
|
||||
}
|
||||
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
|
||||
if err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
|
||||
if t {
|
||||
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
|
||||
if err := nezha.StartNezhaCompat(l); err != nil {
|
||||
log.Printf("start Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
|
||||
}
|
||||
} else {
|
||||
if err := nezha.StopNezhaCompat(); err != nil {
|
||||
log.Printf("stop Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
// 初始化 cloudflared
|
||||
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
|
||||
log.Printf("failed to auto start cloudflared: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
|
||||
})
|
||||
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
|
||||
|
||||
r.Use(api.IdentityMiddleware())
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.Register(r)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
log.Printf("Starting server on %s ...", flags.Listen)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
OnFatal(err)
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
OnShutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func InitDatabase() {
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
log.Fatalf("server startup failed at %q: %v", "run-1.2.7-upgrade", err)
|
||||
panic(err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化阶段:任一步失败都不应继续对外服务。
|
||||
type stage struct {
|
||||
name string
|
||||
fn func() error
|
||||
}
|
||||
stages := []stage{
|
||||
{"init-stores", app.InitStores},
|
||||
{"init-providers", app.InitProviders},
|
||||
{"start-background", app.StartBackground},
|
||||
{"build-router", app.BuildRouter},
|
||||
}
|
||||
for _, s := range stages {
|
||||
if err := s.fn(); err != nil {
|
||||
// 已登记的资源尽力回收,再退出。
|
||||
_ = app.Shutdown()
|
||||
log.Fatalf("server startup failed at %q: %v", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("server exited with error: %v", err)
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
}
|
||||
}
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
if err := tasks.ReloadPingSchedule(); err != nil {
|
||||
log.Println("Failed to reload ping schedule:", err)
|
||||
}
|
||||
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
|
||||
log.Println("Failed to reload load notification schedule:", err)
|
||||
}
|
||||
records.CompactRecord()
|
||||
|
||||
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
|
||||
log.Println("Failed to add cleanup scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
|
||||
log.Println("Failed to add minute scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
|
||||
log.Println("Failed to add expire notification scheduled task:", err)
|
||||
}
|
||||
notifier.InitTrafficReportSchedule()
|
||||
}
|
||||
|
||||
func cleanupScheduledData() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
auditlog.RemoveOldLogs()
|
||||
accounts.RemoveExpiredSessions()
|
||||
}
|
||||
|
||||
func minuteScheduledWork() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
report_cache.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
notifier.CheckTraffic()
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
corn.StopAll()
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
@@ -100,8 +100,8 @@ func CreateDefaultAdminAccount() (username, passwd string, err error) {
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
SSOID: "",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
@@ -171,7 +171,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
if sso_type != nil {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
updates["updated_at"] = time.Now()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -33,11 +33,11 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
|
||||
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: time.Now().UTC(),
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
}
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
@@ -50,7 +50,7 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now().UTC(),
|
||||
Time: time.Now(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
@@ -73,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(sessionRecord.Expires) {
|
||||
if time.Now().After(sessionRecord.Expires.ToTime()) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -111,10 +111,24 @@ func DeleteAllSessions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateLatestOnline(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
|
||||
}
|
||||
|
||||
func UpdateLatestUserAgent(session, userAgent string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
|
||||
}
|
||||
func UpdateLatestIp(session, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
|
||||
}
|
||||
|
||||
func UpdateLatest(session, useragent, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
|
||||
"latest_online": time.Now().UTC(),
|
||||
"latest_online": time.Now(),
|
||||
"latest_user_agent": useragent,
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
@@ -122,7 +136,7 @@ func UpdateLatest(session, useragent, ip string) error {
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
|
||||
result := db.Where("expires < ?", time.Now()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
)
|
||||
|
||||
func Log(ip, uuid, message, msgType string) {
|
||||
now := time.Now()
|
||||
db := dbcore.GetDBInstance()
|
||||
logEntry := &models.Log{
|
||||
IP: ip,
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: time.Now().UTC(),
|
||||
Time: models.FromTime(now),
|
||||
}
|
||||
db.Create(logEntry)
|
||||
}
|
||||
@@ -27,7 +28,7 @@ func EventLog(eventType, message string) {
|
||||
// Delete logs older than 30 days
|
||||
func RemoveOldLogs() {
|
||||
db := dbcore.GetDBInstance()
|
||||
threshold := time.Now().UTC().AddDate(0, 0, -30)
|
||||
threshold := time.Now().AddDate(0, 0, -30)
|
||||
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
|
||||
log.Println("Failed to remove old logs:", err)
|
||||
}
|
||||
|
||||
+38
-35
@@ -1,7 +1,6 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,20 +8,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func DeleteClient(clientUuid string) error {
|
||||
// Clean the independent metric store before removing the client from the main DB.
|
||||
if err := metricstore.DeleteEntity(context.Background(), clientUuid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
if err != nil {
|
||||
@@ -43,7 +37,7 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
@@ -128,6 +122,24 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientName(clientUUID, clientName string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientToken(clientUUID, token string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateClient 创建新客户端
|
||||
func CreateClient() (clientUUID, token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
@@ -138,8 +150,8 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
@@ -163,8 +175,8 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
@@ -198,6 +210,19 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
|
||||
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
|
||||
}
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -236,30 +261,8 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, exists := updates["expired_at"]; exists {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
updates["expired_at"] = nil
|
||||
case time.Time:
|
||||
updates["expired_at"] = typed.UTC()
|
||||
case *time.Time:
|
||||
if typed == nil {
|
||||
updates["expired_at"] = nil
|
||||
} else {
|
||||
updates["expired_at"] = typed.UTC()
|
||||
}
|
||||
case string:
|
||||
stamp, err := time.Parse(time.RFC3339Nano, typed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
|
||||
}
|
||||
updates["expired_at"] = stamp.UTC()
|
||||
default:
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -1,14 +1,66 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var reportSaveLocks sync.Map
|
||||
|
||||
func getReportSaveLock(clientUUID string) *sync.Mutex {
|
||||
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
|
||||
var record models.Record
|
||||
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Report 表示客户端报告数据
|
||||
// SaveReport 保存报告数据
|
||||
func SaveReport(uuid string, data map[string]interface{}) (err error) {
|
||||
|
||||
report, err := ParseReport(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -19,6 +71,18 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
@@ -102,3 +166,132 @@ func ReportVerify(report v1.Report) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := ReportVerify(report); err != nil {
|
||||
return fmt.Errorf("failed to save Record: %w", err)
|
||||
}
|
||||
|
||||
// 保存GPU详细记录到独立表
|
||||
currentTime := time.Now()
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
for idx, gpu := range report.GPU.DetailedInfo {
|
||||
gpuRecord := models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(currentTime),
|
||||
DeviceIndex: idx,
|
||||
DeviceName: gpu.Name,
|
||||
MemTotal: gpu.MemoryTotal,
|
||||
MemUsed: gpu.MemoryUsed,
|
||||
Utilization: float32(gpu.Utilization),
|
||||
Temperature: gpu.Temperature,
|
||||
}
|
||||
if err := db.Create(&gpuRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save GPU record: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均GPU使用率,用于向后兼容
|
||||
averageGPUUsage := float32(0)
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
averageGPUUsage = float32(report.GPU.AverageUsage)
|
||||
}
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(currentTime),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: averageGPUUsage, // 使用平均GPU使用率
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
SwapTotal: report.Swap.Total,
|
||||
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
|
||||
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
|
||||
Disk: report.Disk.Used,
|
||||
DiskTotal: report.Disk.Total,
|
||||
NetIn: report.Network.Down,
|
||||
NetOut: report.Network.Up,
|
||||
NetTotalUp: report.Network.TotalUp,
|
||||
NetTotalDown: report.Network.TotalDown,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
//Uptime: report.Uptime,
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
previous, err := getLatestTrafficRecord(tx, clientUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load previous Record: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
Record.TrafficUp = utils.ComputeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
|
||||
Record.TrafficDown = utils.ComputeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
|
||||
}
|
||||
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// getString 从 map 中获取字符串
|
||||
func getString(data map[string]interface{}, key string) string {
|
||||
if val, ok := data[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt 从 map 中获取整数
|
||||
func getInt(data map[string]interface{}, key string) int {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getInt64 从 map 中获取 int64
|
||||
func getInt64(data map[string]interface{}, key string) int64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int64(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFloat 从 map 中获取 float64
|
||||
func getFloat(data map[string]interface{}, key string) float64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,83 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
|
||||
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
|
||||
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.want, utils.ComputeTrafficDelta(test.current, test.previous))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportSaveLockSerializesSameClient(t *testing.T) {
|
||||
clientUUID := "client-lock-same"
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
secondLock := getReportSaveLock(clientUUID)
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("same-client report save lock was acquired while already held")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
lock.Unlock()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("same-client report save lock was not released")
|
||||
}
|
||||
lock.Lock()
|
||||
}
|
||||
|
||||
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
|
||||
firstLock := getReportSaveLock("client-lock-a")
|
||||
firstLock.Lock()
|
||||
defer firstLock.Unlock()
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
waitGroup.Add(1)
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
secondLock := getReportSaveLock("client-lock-b")
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("different-client report save lock should not block")
|
||||
}
|
||||
waitGroup.Wait()
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
@@ -10,8 +8,6 @@ import (
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
cb.CreatedAt = time.Now().UTC()
|
||||
cb.UpdatedAt = cb.CreatedAt
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
@@ -28,8 +24,6 @@ func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
delete(fields, "created_at")
|
||||
fields["updated_at"] = time.Now().UTC()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
|
||||
+140
-256
@@ -168,101 +168,8 @@ func unzipToDir(zipPath, dstDir string) error {
|
||||
var (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
|
||||
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
|
||||
// 也避免额外的裸文件依赖。
|
||||
const SystemVersionKey = "system_version"
|
||||
|
||||
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
|
||||
var versionID string
|
||||
|
||||
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
|
||||
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
|
||||
// 之前采集。
|
||||
var dbFileExistedAtStartup bool
|
||||
|
||||
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
|
||||
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
|
||||
func SetVersionID(id string) {
|
||||
versionID = id
|
||||
}
|
||||
|
||||
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
|
||||
func resolveDatabaseFile() string {
|
||||
dbFile := flags.DatabaseFile
|
||||
if dbFile == "" {
|
||||
dbFile = "./data/komari.db"
|
||||
}
|
||||
return dbFile
|
||||
}
|
||||
|
||||
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
|
||||
// ./backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
|
||||
//
|
||||
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
|
||||
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
|
||||
//
|
||||
// 触发规则:
|
||||
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
|
||||
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
|
||||
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
|
||||
// - 配置中版本与当前不同:版本升级,备份。
|
||||
// - 配置中版本与当前一致:无需备份。
|
||||
//
|
||||
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
|
||||
func backupOnVersionUpgrade() {
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
if versionRecorded && prevVersion == versionID {
|
||||
return
|
||||
}
|
||||
|
||||
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
|
||||
if !versionRecorded && !dbFileExistedAtStartup {
|
||||
writeVersionMarker()
|
||||
return
|
||||
}
|
||||
|
||||
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
|
||||
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
|
||||
// 避免备份出的库缺少仍留在 -wal 中的写入。
|
||||
if instance != nil {
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[upgrade-backup] failed to create backup dir: %v", err)
|
||||
return
|
||||
}
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("upgrade-%s.zip", tsName))
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
|
||||
return
|
||||
}
|
||||
log.Printf("[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
|
||||
|
||||
writeVersionMarker()
|
||||
}
|
||||
|
||||
// writeVersionMarker 将当前 versionID 写入配置库。
|
||||
func writeVersionMarker() {
|
||||
if err := config.Set(SystemVersionKey, versionID); err != nil {
|
||||
log.Printf("[upgrade-backup] failed to persist version marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQLiteDSN(databaseFile string) string {
|
||||
if databaseFile == "" {
|
||||
databaseFile = "./data/komari.db"
|
||||
@@ -285,183 +192,160 @@ func buildSQLiteDSN(databaseFile string) string {
|
||||
return "file:" + filepath.ToSlash(databaseFile) + separator + params
|
||||
}
|
||||
|
||||
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
|
||||
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
|
||||
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
|
||||
func Initialize() error {
|
||||
once.Do(func() {
|
||||
initErr = doInitialize()
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetDBInstance 返回全局数据库实例。
|
||||
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
|
||||
// 需要错误处理的启动流程应优先调用 Initialize()。
|
||||
func GetDBInstance() *gorm.DB {
|
||||
if err := Initialize(); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
once.Do(func() {
|
||||
|
||||
// Close 关闭底层数据库连接,供关闭流程调用。
|
||||
func Close() error {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := instance.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
var err error
|
||||
|
||||
func doInitialize() error {
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
tsName := time.Now().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logutil.NewGormLogger(),
|
||||
}
|
||||
}()
|
||||
|
||||
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
|
||||
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
|
||||
// gorm.Open 会创建空库。
|
||||
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
|
||||
dbFileExistedAtStartup = true
|
||||
}
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logutil.NewGormLogger(),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// SQLite 连接
|
||||
// 通过 DSN 传入 _busy_timeout / _txlock 等参数,确保连接池中的每一条连接
|
||||
// 都生效:
|
||||
// - _busy_timeout=5000:遇到写锁时最多等待 5s 再返回,避免瞬时
|
||||
// "database is locked" 直接失败(仅靠后续 PRAGMA Exec 只作用于
|
||||
// 当时执行该语句的单条连接,池内其它连接不生效)。
|
||||
// - _txlock=immediate:事务一开始即获取写锁,避免「先 SELECT 后写」
|
||||
// 的锁升级在并发写入下产生死锁式的立即 SQLITE_BUSY。
|
||||
// - _journal_mode=WAL / _synchronous=NORMAL:与下方 PRAGMA 保持一致,
|
||||
// 在 DSN 层为所有连接预设。
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// SQLite 连接
|
||||
// 通过 DSN 传入 _busy_timeout / _txlock 等参数,确保连接池中的每一条连接
|
||||
// 都生效:
|
||||
// - _busy_timeout=5000:遇到写锁时最多等待 5s 再返回,避免瞬时
|
||||
// "database is locked" 直接失败(仅靠后续 PRAGMA Exec 只作用于
|
||||
// 当时执行该语句的单条连接,池内其它连接不生效)。
|
||||
// - _txlock=immediate:事务一开始即获取写锁,避免「先 SELECT 后写」
|
||||
// 的锁升级在并发写入下产生死锁式的立即 SQLITE_BUSY。
|
||||
// - _journal_mode=WAL / _synchronous=NORMAL:与下方 PRAGMA 保持一致,
|
||||
// 在 DSN 层为所有连接预设。
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
if sqlDB, dbErr := instance.DB(); dbErr == nil {
|
||||
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
|
||||
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
|
||||
// ping 结果等短写入撞锁并导致整批负载记录回滚。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
} else {
|
||||
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
|
||||
}
|
||||
instance.Exec("PRAGMA wal = ON;")
|
||||
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
}
|
||||
instance.Exec("PRAGMA synchronous = NORMAL;")
|
||||
instance.Exec("PRAGMA busy_timeout = 5000;")
|
||||
instance.Exec("PRAGMA cache_size = -65536;")
|
||||
instance.Exec("PRAGMA temp_store = MEMORY;")
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
log.Fatalf("Failed to run startup migrations: %v", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.GPURecord{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingRecord{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
|
||||
log.Fatalf("Failed to create tables: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
if sqlDB, dbErr := instance.DB(); dbErr == nil {
|
||||
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
|
||||
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
|
||||
// ping 结果等短写入撞锁并导致整批负载记录回滚。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
} else {
|
||||
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
|
||||
err = instance.Table("records_long_term").AutoMigrate(
|
||||
&models.Record{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
instance.Exec("PRAGMA wal = ON;")
|
||||
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
err = instance.Table("gpu_records_long_term").AutoMigrate(
|
||||
&models.GPURecord{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
instance.Exec("PRAGMA synchronous = NORMAL;")
|
||||
instance.Exec("PRAGMA busy_timeout = 5000;")
|
||||
instance.Exec("PRAGMA cache_size = -65536;")
|
||||
instance.Exec("PRAGMA temp_store = MEMORY;")
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
return fmt.Errorf("failed to run startup migrations: %w", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
|
||||
// 配置库就绪后、执行后续 AutoMigrate 与一次性 metrics 迁移之前:
|
||||
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
|
||||
backupOnVersionUpgrade()
|
||||
// Manually create composite indexes
|
||||
if flags.IsSQLite() {
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_lt_client_time ON gpu_records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_ping_record_client_time ON ping_records(client, time)")
|
||||
}
|
||||
|
||||
// 自动迁移模型
|
||||
//
|
||||
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
|
||||
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
|
||||
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
|
||||
// 若升级时旧表仍存在,会在 pkg/migrations.RunMetricStoreMigrations 中先导入再清理。
|
||||
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
|
||||
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
|
||||
})
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
); err != nil {
|
||||
log.Printf("Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
); err != nil {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return instance
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
var maintenanceMu sync.Mutex
|
||||
|
||||
// StorageSize returns the bytes occupied by the main SQLite database and its
|
||||
// WAL/SHM sidecar files.
|
||||
func StorageSize() (int64, error) {
|
||||
if !flags.IsSQLite() {
|
||||
return 0, errors.New("main database size is only available for SQLite")
|
||||
}
|
||||
return sqliteFileSetSize(resolveDatabaseFile())
|
||||
}
|
||||
|
||||
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
|
||||
func ReclaimSpace(ctx context.Context) error {
|
||||
if !flags.IsSQLite() {
|
||||
return errors.New("main database maintenance is only supported for SQLite")
|
||||
}
|
||||
|
||||
maintenanceMu.Lock()
|
||||
defer maintenanceMu.Unlock()
|
||||
|
||||
db, err := GetDBInstance().DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get main database connection: %w", err)
|
||||
}
|
||||
if err := checkpointSQLiteWAL(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
|
||||
return fmt.Errorf("vacuum main database: %w", err)
|
||||
}
|
||||
return checkpointSQLiteWAL(ctx, db)
|
||||
}
|
||||
|
||||
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
return fmt.Errorf("checkpoint main database WAL: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf(
|
||||
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
|
||||
logFrames,
|
||||
checkpointedFrames,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteFileSetSize(dsn string) (int64, error) {
|
||||
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
|
||||
if index := strings.IndexByte(path, '?'); index >= 0 {
|
||||
path = path[:index]
|
||||
}
|
||||
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
|
||||
return 0, errors.New("database is not backed by a local file")
|
||||
}
|
||||
|
||||
var total int64
|
||||
foundDatabase := false
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
info, err := os.Stat(path + suffix)
|
||||
switch {
|
||||
case err == nil:
|
||||
total += info.Size()
|
||||
if suffix == "" {
|
||||
foundDatabase = true
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
continue
|
||||
default:
|
||||
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
|
||||
}
|
||||
}
|
||||
if !foundDatabase {
|
||||
return 0, fmt.Errorf("database file %q does not exist", path)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
files := map[string]int{
|
||||
databasePath: 11,
|
||||
databasePath + "-wal": 7,
|
||||
databasePath + "-shm": 5,
|
||||
}
|
||||
for path, size := range files {
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
|
||||
t.Fatalf("write fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
|
||||
if err != nil {
|
||||
t.Fatalf("sqliteFileSetSize: %v", err)
|
||||
}
|
||||
if got != 23 {
|
||||
t.Fatalf("size = %d, want 23", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
|
||||
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
|
||||
if _, err := sqliteFileSetSize(dsn); err == nil {
|
||||
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStoreNotInitialized = errors.New("metric store not initialized")
|
||||
ErrStoreBusy = errors.New("metric store is busy")
|
||||
)
|
||||
|
||||
// StorageInfo describes the physical storage owned by the active metric store.
|
||||
// Size remains useful even when the store points at an external database because
|
||||
// pkg/metric limits its query to the three tables managed by this Store.
|
||||
type StorageInfo struct {
|
||||
Driver metric.Driver
|
||||
Action metric.MaintenanceAction
|
||||
Size int64
|
||||
}
|
||||
|
||||
// MaintenanceResult keeps measurement failures separate from the maintenance
|
||||
// error. A database may allow table maintenance while denying catalog queries,
|
||||
// and callers should still be able to report that the operation succeeded.
|
||||
type MaintenanceResult struct {
|
||||
Driver metric.Driver
|
||||
Action metric.MaintenanceAction
|
||||
Before int64
|
||||
After int64
|
||||
BeforeSizeError error
|
||||
AfterSizeError error
|
||||
}
|
||||
|
||||
// InspectStorage reads physical storage information while preventing a store
|
||||
// reload from closing the active connection underneath the query.
|
||||
func InspectStorage(ctx context.Context) (StorageInfo, error) {
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return StorageInfo{}, fmt.Errorf("wait for metric store operations before inspection: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return StorageInfo{}, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
info := StorageInfo{
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
size, err := activeStore.StorageSize(ctx)
|
||||
info.Size = size
|
||||
return info, err
|
||||
}
|
||||
|
||||
// ReclaimSpace performs the driver-specific physical maintenance operation.
|
||||
// It shares the exclusive operation lock with Compact so retention compaction
|
||||
// and a table/file rewrite can never run against the metric store concurrently.
|
||||
func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
if store == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
return MaintenanceResult{
|
||||
Driver: store.Driver(),
|
||||
Action: store.MaintenanceAction(),
|
||||
BeforeSizeError: ErrStoreBusy,
|
||||
AfterSizeError: ErrStoreBusy,
|
||||
}, ErrStoreBusy
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
result := MaintenanceResult{
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
result.Before, result.BeforeSizeError = activeStore.StorageSize(ctx)
|
||||
maintenanceErr := activeStore.ReclaimSpace(ctx)
|
||||
result.After, result.AfterSizeError = activeStore.StorageSize(ctx)
|
||||
return result, maintenanceErr
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
func TestInspectAndReclaimStorage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
info, err := InspectStorage(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect storage: %v", err)
|
||||
}
|
||||
if info.Driver != metric.DriverSQLite || info.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("unexpected storage info: %#v", info)
|
||||
}
|
||||
if info.Size != 0 {
|
||||
t.Fatalf("in-memory storage size = %d, want 0", info.Size)
|
||||
}
|
||||
|
||||
result, err := ReclaimSpace(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reclaim space: %v", err)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("unexpected maintenance result: %#v", result)
|
||||
}
|
||||
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
|
||||
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimSpaceReportsBusyStore(t *testing.T) {
|
||||
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
if !storeOperations.TryAcquire() {
|
||||
t.Fatal("acquire store operation gate")
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
result, err := ReclaimSpace(context.Background())
|
||||
if !errors.Is(err, ErrStoreBusy) {
|
||||
t.Fatalf("reclaim error = %v, want %v", err, ErrStoreBusy)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("busy result lost store metadata: %#v", result)
|
||||
}
|
||||
if !errors.Is(result.BeforeSizeError, ErrStoreBusy) || !errors.Is(result.AfterSizeError, ErrStoreBusy) {
|
||||
t.Fatalf("busy result should mark both measurements unavailable: %#v", result)
|
||||
}
|
||||
if _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
|
||||
t.Fatalf("compact error = %v, want %v", err, ErrCompactInProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStorageRequiresInitializedStore(t *testing.T) {
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = nil
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
})
|
||||
|
||||
if _, err := InspectStorage(context.Background()); !errors.Is(err, ErrStoreNotInitialized) {
|
||||
t.Fatalf("inspect error = %v, want %v", err, ErrStoreNotInitialized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseStoreContextCancelsMigrationBeforeTakingStoreLock(t *testing.T) {
|
||||
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
migrationCtx, cancelMigration := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
if !storeOperations.TryAcquire() {
|
||||
t.Fatal("acquire store operation gate")
|
||||
}
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = cancelMigration
|
||||
storeMigDone = done
|
||||
storeMigMu.Unlock()
|
||||
|
||||
go func() {
|
||||
<-migrationCtx.Done()
|
||||
storeOperations.Release()
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = nil
|
||||
storeMigDone = nil
|
||||
close(done)
|
||||
storeMigMu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := CloseStoreContext(ctx); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if migrationCtx.Err() == nil {
|
||||
t.Fatal("store migration was not canceled before close")
|
||||
}
|
||||
if err := Reload(context.Background()); !errors.Is(err, ErrStoreBusy) {
|
||||
t.Fatalf("reload after close error = %v, want %v", err, ErrStoreBusy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOperationWaitsRespectContext(t *testing.T) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
t.Fatal("acquire store operation gate")
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := InspectStorage(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("inspect error = %v, want context canceled", err)
|
||||
}
|
||||
if err := Reload(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("reload error = %v, want context canceled", err)
|
||||
}
|
||||
if err := CloseStoreContext(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("close error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installTestStore(t *testing.T, s *metric.Store) {
|
||||
t.Helper()
|
||||
storeMigMu.Lock()
|
||||
previousClosing := storeClosing
|
||||
storeClosing = false
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
previousFingerprint := storeFingerprint
|
||||
store = s
|
||||
storeFingerprint = "test|memory"
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = previousClosing
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeFingerprint = previousFingerprint
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
const (
|
||||
MetricCPU = "cpu.usage"
|
||||
MetricGPU = "gpu.usage"
|
||||
MetricGPUDeviceUsage = "gpu.device.usage"
|
||||
MetricGPUMem = "gpu.memory.used"
|
||||
MetricGPUMemTotal = "gpu.memory.total"
|
||||
MetricGPUTemp = "gpu.temperature"
|
||||
MetricRAM = "memory.used"
|
||||
MetricRAMTotal = "memory.total"
|
||||
MetricSwap = "swap.used"
|
||||
MetricSwapTotal = "swap.total"
|
||||
MetricLoad = "load.average"
|
||||
MetricTemp = "temperature"
|
||||
MetricDisk = "disk.used"
|
||||
MetricDiskTotal = "disk.total"
|
||||
MetricNetIn = "net.in.rate"
|
||||
MetricNetOut = "net.out.rate"
|
||||
MetricNetTotalUp = "net.total.up"
|
||||
MetricNetTotalDown = "net.total.down"
|
||||
MetricTrafficUp = "traffic.up"
|
||||
MetricTrafficDown = "traffic.down"
|
||||
MetricProcess = "process.count"
|
||||
MetricConnections = "connections.tcp"
|
||||
MetricConnectionsUDP = "connections.udp"
|
||||
MetricPingLatency = "ping.latency_ms"
|
||||
MetricPingLoss = "ping.loss"
|
||||
)
|
||||
|
||||
// loadRecordMetricNames are the entity-level metrics used to reconstruct the
|
||||
// legacy Record response shape.
|
||||
var loadRecordMetricNames = []string{
|
||||
MetricCPU, MetricGPU, MetricRAM, MetricRAMTotal, MetricSwap, MetricSwapTotal,
|
||||
MetricLoad, MetricTemp, MetricDisk, MetricDiskTotal, MetricNetIn, MetricNetOut,
|
||||
MetricNetTotalUp, MetricNetTotalDown, MetricTrafficUp, MetricTrafficDown,
|
||||
MetricProcess, MetricConnections, MetricConnectionsUDP,
|
||||
}
|
||||
|
||||
// gpuDeviceRecordMetricNames are stored separately from the entity-level GPU
|
||||
// average and are included when deleting all system records.
|
||||
var gpuDeviceRecordMetricNames = []string{
|
||||
MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp,
|
||||
}
|
||||
|
||||
var recordMetricNames = joinMetricNames(loadRecordMetricNames, gpuDeviceRecordMetricNames)
|
||||
|
||||
// Ping has an independent retention and cleanup boundary.
|
||||
var pingMetricNames = []string{MetricPingLatency, MetricPingLoss}
|
||||
|
||||
func joinMetricNames(groups ...[]string) []string {
|
||||
total := 0
|
||||
for _, group := range groups {
|
||||
total += len(group)
|
||||
}
|
||||
names := make([]string, 0, total)
|
||||
for _, group := range groups {
|
||||
names = append(names, group...)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
store *metric.Store
|
||||
storeFingerprint string
|
||||
storeMu sync.RWMutex
|
||||
storeOnce sync.Once
|
||||
storeOperations = newStoreOperationGate()
|
||||
compactAt int
|
||||
)
|
||||
|
||||
var ErrCompactInProgress = errors.New("metric store compact already in progress")
|
||||
|
||||
const (
|
||||
// DefaultRollupRawRetention keeps a short hot raw window; older samples are
|
||||
// served from rollups after compaction.
|
||||
DefaultRollupRawRetention = 15 * time.Minute
|
||||
DefaultRollupFinestTier = time.Minute
|
||||
)
|
||||
|
||||
// MetricStoreConfig 保存 metric store 配置。
|
||||
//
|
||||
// 注意:metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃)。
|
||||
// 未显式配置时默认使用 SQLite(./data/metrics.db)。
|
||||
type MetricStoreConfig struct {
|
||||
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
|
||||
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
|
||||
DownsamplingEnabled bool `json:"metric_downsampling_enabled" default:"false"` // 是否启用分层降采样
|
||||
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
|
||||
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
|
||||
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
|
||||
}
|
||||
|
||||
// MetricStoreConfigKeys 配置键
|
||||
//
|
||||
// MetricStoreEnabledKey 已废弃:metric store 始终启用,保留常量仅用于清理旧配置。
|
||||
const (
|
||||
MetricStoreEnabledKey = "metric_store_enabled" // Deprecated: metric store 始终启用
|
||||
MetricDBDriverKey = "metric_db_driver"
|
||||
MetricDBDSNKey = "metric_db_dsn"
|
||||
MetricDownsamplingEnabledKey = "metric_downsampling_enabled"
|
||||
MetricTablePrefixKey = "metric_table_prefix"
|
||||
MetricMaxOpenConnsKey = "metric_max_open_conns"
|
||||
MetricMaxIdleConnsKey = "metric_max_idle_conns"
|
||||
// MigrationTargetKey 记录上一次成功完成启动迁移的目标指纹(driver+dsn)。
|
||||
// 当目标发生变化(例如从 SQLite 切换到 MySQL/PostgreSQL)时,启动迁移会
|
||||
// 重新执行,把上一个目标库的数据搬运到新的目标 metrics 库。
|
||||
MigrationTargetKey = "metric_migration_target"
|
||||
)
|
||||
|
||||
// buildMetricConfig 根据 MetricStoreConfig 构造底层 metric.Config。
|
||||
// autoMigrate 控制是否在 Open 时自动建表:正式初始化/热加载时为 true,
|
||||
// 仅做连接测试时为 false(不写入 schema,避免对目标库产生副作用)。
|
||||
func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config, error) {
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
|
||||
tablePrefix := cfg.TablePrefix
|
||||
if tablePrefix == "" {
|
||||
tablePrefix = "metric_"
|
||||
}
|
||||
opts := []metric.Option{
|
||||
metric.WithTablePrefix(tablePrefix),
|
||||
metric.WithAutoMigrate(autoMigrate),
|
||||
}
|
||||
if cfg.DownsamplingEnabled {
|
||||
opts = append(opts, metric.WithRollupPolicy(defaultRollupPolicy()))
|
||||
}
|
||||
|
||||
switch driver {
|
||||
case metric.DriverSQLite:
|
||||
dsn := cfg.DSN
|
||||
if dsn == "" || dsn == "./data/metrics.db" {
|
||||
// 注意:刻意不使用 cache=shared。SQLite 共享缓存模式使用表级锁,
|
||||
// 当一个连接持有读锁、另一个连接尝试写入时会立即返回
|
||||
// SQLITE_LOCKED("database table is locked"),且 busy_timeout
|
||||
// 对共享缓存的表级锁无效,迁移期间与前台查询/实时写入并发时必然报错。
|
||||
// _txlock=immediate 让写事务开始即获取写锁,避免锁升级死锁。
|
||||
dsn = "file:./data/metrics.db?mode=rwc&_txlock=immediate"
|
||||
} else {
|
||||
// 用户自定义 DSN 时,剥离 cache=shared,避免上述表级锁问题。
|
||||
dsn = stripSharedCache(dsn)
|
||||
}
|
||||
// SQLite 串行化写入:固定单写连接以避免 "database is locked" 竞争,
|
||||
// 同时启用独立的 WAL 只读连接池提升前台查询并发(写仍走单主连接)。
|
||||
// 这里刻意忽略 cfg.MaxOpenConns/MaxIdleConns —— 对 SQLite 而言多写连接
|
||||
// 只会引入锁竞争而非提升吞吐。
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithSQLiteReadPool(4),
|
||||
)
|
||||
return metric.SQLite(dsn, opts...), nil
|
||||
case metric.DriverMySQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.MySQL(cfg.DSN, opts...), nil
|
||||
case metric.DriverPostgreSQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.PostgreSQL(cfg.DSN, opts...), nil
|
||||
default:
|
||||
return metric.Config{}, fmt.Errorf("unsupported metric database driver: %s", cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRollupPolicy() metric.RollupPolicy {
|
||||
return metric.RollupPolicy{
|
||||
RawRetention: DefaultRollupRawRetention,
|
||||
Tiers: []metric.RollupTier{
|
||||
{Interval: DefaultRollupFinestTier, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveDriverFromConfig 根据 DSN 自动推断 metrics 数据库类型;当 DSN 不能可靠
|
||||
// 识别时回退到旧配置中的 driver,以兼容已有配置和非常规 DSN。
|
||||
func ResolveDriverFromConfig(configuredDriver, dsn string) metric.Driver {
|
||||
if driver, ok := InferDriverFromDSN(dsn); ok {
|
||||
return driver
|
||||
}
|
||||
|
||||
switch driver := metric.Driver(strings.ToLower(strings.TrimSpace(configuredDriver))); driver {
|
||||
case metric.DriverSQLite, metric.DriverMySQL, metric.DriverPostgreSQL:
|
||||
return driver
|
||||
default:
|
||||
return metric.DriverSQLite
|
||||
}
|
||||
}
|
||||
|
||||
// InferDriverFromDSN 尽量根据常见 DSN 格式推断数据库类型。
|
||||
// 返回 ok=false 表示格式不够明确,调用方应使用已有配置作为兜底。
|
||||
func InferDriverFromDSN(dsn string) (metric.Driver, bool) {
|
||||
raw := strings.TrimSpace(dsn)
|
||||
if raw == "" {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
|
||||
// PostgreSQL URL DSN: postgres://... 或 postgresql://...
|
||||
if strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://") {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// SQLite 常见文件/内存 DSN。
|
||||
if raw == ":memory:" || strings.HasPrefix(lower, "file:") || strings.HasPrefix(lower, "sqlite://") || strings.HasPrefix(lower, "sqlite3://") {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
// MySQL URL(虽然 go-sql-driver/mysql 原生 DSN 通常不是 URL,但这里用于给出
|
||||
// 类型推断;连接测试仍会校验 DSN 是否可被驱动接受)。
|
||||
if strings.HasPrefix(lower, "mysql://") {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// PostgreSQL 关键字/值 DSN: host=... user=... dbname=...
|
||||
if looksLikePostgreSQLKeyValueDSN(lower) {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// go-sql-driver/mysql DSN: user:pass@tcp(host:3306)/db、user@unix(...)/db、user:pass@/db 等。
|
||||
if looksLikeMySQLDSN(lower) {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// SQLite 路径:./data/metrics.db、/var/lib/metrics.sqlite3、metrics.sqlite 等。
|
||||
if looksLikeSQLitePath(lower) {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func looksLikePostgreSQLKeyValueDSN(lower string) bool {
|
||||
if !strings.Contains(lower, "=") || strings.Contains(lower, "://") {
|
||||
return false
|
||||
}
|
||||
keys := []string{"host=", "user=", "password=", "dbname=", "port=", "sslmode="}
|
||||
matched := 0
|
||||
for _, key := range keys {
|
||||
if strings.Contains(lower, key) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
// dbname= 基本是 PostgreSQL libpq DSN 的强特征;否则至少匹配两个常见键。
|
||||
return strings.Contains(lower, "dbname=") || matched >= 2
|
||||
}
|
||||
|
||||
func looksLikeMySQLDSN(lower string) bool {
|
||||
if strings.Contains(lower, "://") || strings.Contains(lower, " ") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "@tcp(") || strings.Contains(lower, "@unix(") || strings.Contains(lower, "@/") {
|
||||
return true
|
||||
}
|
||||
// user:pass@host/db、user@host/db 这类虽然不是推荐格式,但也明显偏 MySQL。
|
||||
return strings.Contains(lower, "@") && strings.Contains(lower, "/")
|
||||
}
|
||||
|
||||
func looksLikeSQLitePath(lower string) bool {
|
||||
path := lower
|
||||
if idx := strings.IndexAny(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
return strings.HasSuffix(path, ".db") || strings.HasSuffix(path, ".sqlite") || strings.HasSuffix(path, ".sqlite3")
|
||||
}
|
||||
|
||||
// stripSharedCache 从 SQLite DSN 中移除 cache=shared 参数,避免共享缓存模式下的
|
||||
// 表级锁(SQLITE_LOCKED "database table is locked")。其它参数保持不变。
|
||||
func stripSharedCache(dsn string) string {
|
||||
if !strings.Contains(dsn, "cache=shared") {
|
||||
return dsn
|
||||
}
|
||||
idx := strings.Index(dsn, "?")
|
||||
if idx < 0 {
|
||||
return dsn
|
||||
}
|
||||
base := dsn[:idx]
|
||||
query := dsn[idx+1:]
|
||||
parts := strings.Split(query, "&")
|
||||
kept := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p == "cache=shared" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "?" + strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
// openStore 按配置打开 metric store 并创建指标定义。
|
||||
func openStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
return openStoreWithDefaultRetention(ctx, cfg, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
func openStoreWithDefaultRetention(ctx context.Context, cfg *MetricStoreConfig, defaultRetentionDays int) (*metric.Store, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open metric store: %w", err)
|
||||
}
|
||||
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, defaultRetentionDays); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("failed to create metric definitions: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// OpenStore opens an isolated metric store using the supplied configuration.
|
||||
// It is used by the pre-start upgrade flow before the process-wide store is
|
||||
// initialized. The caller owns the returned store and must close it.
|
||||
func OpenStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
return openStore(ctx, cfg)
|
||||
}
|
||||
|
||||
// OpenStoreForMigration opens an isolated target and uses the legacy data span
|
||||
// as the initial retention for definitions that do not exist yet. Existing
|
||||
// definitions keep their configured retention, including an explicit zero.
|
||||
func OpenStoreForMigration(ctx context.Context, cfg *MetricStoreConfig, legacyRetentionDays int) (*metric.Store, error) {
|
||||
if legacyRetentionDays < defaultBuiltinMetricRetentionDays {
|
||||
legacyRetentionDays = defaultBuiltinMetricRetentionDays
|
||||
}
|
||||
return openStoreWithDefaultRetention(ctx, cfg, legacyRetentionDays)
|
||||
}
|
||||
|
||||
// TestConnection 使用给定配置尝试连接 metrics 数据库(不影响当前运行的 store)。
|
||||
// 仅打开连接并 Ping,不执行自动建表,连接成功后立即关闭。失败时返回可读错误。
|
||||
func TestConnection(ctx context.Context, cfg *MetricStoreConfig) error {
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
return s.Ping(ctx)
|
||||
}
|
||||
|
||||
// InitializeStore 初始化 metric store(启动时调用,仅执行一次)。
|
||||
func InitializeStore() error {
|
||||
var initErr error
|
||||
storeOnce.Do(func() {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to load metric store config: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// metric store 始终启用;未配置时默认 SQLite(./data/metrics.db)。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s, err := openStore(ctx, cfg)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
store = s
|
||||
storeFingerprint = targetFingerprint(cfg)
|
||||
storeMu.Unlock()
|
||||
clearStoreClosing()
|
||||
|
||||
log.Printf("Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
})
|
||||
|
||||
return initErr
|
||||
}
|
||||
|
||||
// Reload 根据最新配置热重载 metric store,无需重启进程。
|
||||
// metric store 始终启用:用新配置打开并建表(内部已 Ping 校验连接),
|
||||
// 成功后再替换运行中的 store,最后关闭旧实例。任何失败都会保留旧 store 不变。
|
||||
//
|
||||
// 注意:Reload 只切换运行中的连接,不会把旧目标(如 SQLite)中的历史数据
|
||||
// 搬运到新目标(如 MySQL/PostgreSQL)。跨库数据迁移由启动迁移
|
||||
// (RunStartupMigration)在下次启动时按目标指纹自动完成。
|
||||
func Reload(ctx context.Context) error {
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return fmt.Errorf("wait for metric store operations before reload: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
if isStoreClosing() {
|
||||
return ErrStoreBusy
|
||||
}
|
||||
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
|
||||
// 用新配置打开并建表(内部已 Ping 校验连接)。
|
||||
s, err := openStore(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
old := store
|
||||
store = s
|
||||
storeFingerprint = targetFingerprint(cfg)
|
||||
storeMu.Unlock()
|
||||
|
||||
if old != nil {
|
||||
if cerr := old.Close(); cerr != nil {
|
||||
log.Printf("Failed to close previous metric store on reload: %v", cerr)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Metric store reloaded successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStore 获取 metric store 实例(如果未启用返回 nil)
|
||||
func GetStore() *metric.Store {
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
return store
|
||||
}
|
||||
|
||||
// RetentionSummary is the compatibility view of all persisted metric policies.
|
||||
type RetentionSummary struct {
|
||||
AllPositive bool
|
||||
MaxDays int
|
||||
}
|
||||
|
||||
// GetRetentionSummary aggregates the active store's metric definitions. An
|
||||
// empty definition set is not considered record-enabled.
|
||||
func GetRetentionSummary(ctx context.Context) (RetentionSummary, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return RetentionSummary{}, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return RetentionSummary{}, err
|
||||
}
|
||||
return summarizeRetentionDefinitions(defs), nil
|
||||
}
|
||||
|
||||
func summarizeRetentionDefinitions(defs []metric.Definition) RetentionSummary {
|
||||
if len(defs) == 0 {
|
||||
return RetentionSummary{}
|
||||
}
|
||||
summary := RetentionSummary{AllPositive: true}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays <= 0 {
|
||||
summary.AllPositive = false
|
||||
}
|
||||
if def.RetentionDays > summary.MaxDays {
|
||||
summary.MaxDays = def.RetentionDays
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
return 0, ErrCompactInProgress
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return 0, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
|
||||
defs, err := activeStore.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
compactAt = 0
|
||||
return 0, nil
|
||||
}
|
||||
if compactAt >= len(defs) {
|
||||
compactAt = 0
|
||||
}
|
||||
|
||||
total := 0
|
||||
start := compactAt
|
||||
for i := 0; i < len(defs); i++ {
|
||||
idx := (start + i) % len(defs)
|
||||
n, err := activeStore.CompactMetric(ctx, defs[idx].Name, now)
|
||||
if err != nil {
|
||||
compactAt = idx
|
||||
return total, fmt.Errorf("compact metric %q: %w", defs[idx].Name, err)
|
||||
}
|
||||
total += n
|
||||
compactAt = (idx + 1) % len(defs)
|
||||
}
|
||||
if _, err := activeStore.CleanupExpired(ctx, now); err != nil {
|
||||
return total, fmt.Errorf("clean up expired raw metrics: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// CloseStoreContext stops the asynchronous store migration before taking the
|
||||
// store write lock, so shutdown cannot wait forever on the migration's lease.
|
||||
func CloseStoreContext(ctx context.Context) error {
|
||||
if err := stopStoreMigrationForClose(ctx); err != nil {
|
||||
clearStoreClosing()
|
||||
return err
|
||||
}
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
clearStoreClosing()
|
||||
return fmt.Errorf("wait for metric store operations before close: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.Lock()
|
||||
defer storeMu.Unlock()
|
||||
|
||||
if store != nil {
|
||||
err := store.Close()
|
||||
store = nil
|
||||
storeFingerprint = ""
|
||||
return err
|
||||
}
|
||||
storeFingerprint = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
const defaultBuiltinMetricRetentionDays = 1
|
||||
|
||||
// createMetricDefinitions creates built-in definitions with explicit policies.
|
||||
func createMetricDefinitions(ctx context.Context, s *metric.Store) error {
|
||||
return createMetricDefinitionsWithDefaultRetention(ctx, s, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.Store, defaultRetentionDays int) error {
|
||||
if defaultRetentionDays < defaultBuiltinMetricRetentionDays {
|
||||
defaultRetentionDays = defaultBuiltinMetricRetentionDays
|
||||
}
|
||||
definitions := []metric.Definition{
|
||||
{Name: MetricCPU, Type: metric.TypeGauge, Unit: "%", Description: "CPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPU, Type: metric.TypeGauge, Unit: "%", Description: "GPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUDeviceUsage, Type: metric.TypeGauge, Unit: "%", Description: "Per-device GPU utilization", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMem, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMemTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUTemp, Type: metric.TypeGauge, Unit: "°C", Description: "GPU temperature", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricRAM, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricRAMTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricSwap, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricSwapTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricLoad, Type: metric.TypeGauge, Unit: "", Description: "System load average", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTemp, Type: metric.TypeGauge, Unit: "°C", Description: "System temperature", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricDisk, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricDiskTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetIn, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network in rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetOut, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network out rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalUp, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total upload", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalDown, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total download", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficUp, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic upload delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficDown, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic download delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricProcess, Type: metric.TypeGauge, Unit: "count", Description: "Process count", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnections, Type: metric.TypeGauge, Unit: "count", Description: "TCP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnectionsUDP, Type: metric.TypeGauge, Unit: "count", Description: "UDP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLatency, Type: metric.TypeGauge, Unit: "ms", Description: "Ping latency", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLoss, Type: metric.TypeGauge, Unit: "ratio", Description: "Ping packet loss indicator", RetentionDays: defaultRetentionDays},
|
||||
}
|
||||
|
||||
for _, def := range definitions {
|
||||
existing, err := s.GetMetric(ctx, def.Name)
|
||||
if err != nil && !errors.Is(err, metric.ErrNotFound) {
|
||||
return fmt.Errorf("failed to get metric %s: %w", def.Name, err)
|
||||
}
|
||||
if err == nil {
|
||||
if existing.RetentionDays == 0 {
|
||||
if _, err := s.SetMetricRetention(ctx, def.Name, 0); err != nil {
|
||||
return fmt.Errorf("failed to preserve disabled metric %s: %w", def.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
def.RetentionDays = existing.RetentionDays
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, def); err != nil {
|
||||
return fmt.Errorf("failed to create metric %s: %w", def.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WritePingRecord 将 ping 记录写入 metric store
|
||||
func WritePingRecord(ctx context.Context, rec models.PingRecord) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
ts := rec.Time
|
||||
entityID := rec.Client
|
||||
tags := map[string]string{
|
||||
"task_id": fmt.Sprintf("%d", rec.TaskId),
|
||||
}
|
||||
|
||||
loss := 0.0
|
||||
if rec.Value < 0 {
|
||||
loss = 1
|
||||
}
|
||||
points := []metric.Point{
|
||||
{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: entityID,
|
||||
Timestamp: ts,
|
||||
Value: float64(rec.Value),
|
||||
Tags: tags,
|
||||
},
|
||||
{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: entityID,
|
||||
Timestamp: ts,
|
||||
Value: loss,
|
||||
Tags: tags,
|
||||
},
|
||||
}
|
||||
|
||||
return s.WriteBatch(ctx, points)
|
||||
}
|
||||
|
||||
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record
|
||||
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordsByClientAndTimeFromSeries(ctx, s, clientUUID, start, end)
|
||||
}
|
||||
|
||||
// GetRecordsByTime 从 metric store 查询所有客户端在时间范围内的记录
|
||||
func GetRecordsByTime(ctx context.Context, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
interval := recordSeriesInterval(s, start, end, time.Now().UTC())
|
||||
entityIDs, err := listRecordEntityIDs(ctx, s, start, end, interval)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []models.Record
|
||||
for _, entityID := range entityIDs {
|
||||
items, err := getRecordsByClientAndTimeFromSeries(ctx, s, entityID, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, items...)
|
||||
}
|
||||
sortRecords(records)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
type recordSeriesKey struct {
|
||||
client string
|
||||
ts int64
|
||||
}
|
||||
|
||||
func getRecordsByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time) ([]models.Record, error) {
|
||||
now := time.Now().UTC()
|
||||
interval := recordSeriesInterval(s, start, end, now)
|
||||
recordMap := make(map[recordSeriesKey]*models.Record)
|
||||
|
||||
for _, metricName := range loadRecordMetricNames {
|
||||
points, err := s.Series(ctx, metric.AggregateQuery{
|
||||
Query: metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: clientUUID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
},
|
||||
Aggregation: recordMetricAggregation(metricName),
|
||||
Interval: interval,
|
||||
}, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
|
||||
}
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
key := recordSeriesKey{client: entityID, ts: point.Bucket.Unix()}
|
||||
if recordMap[key] == nil {
|
||||
recordMap[key] = &models.Record{
|
||||
Client: entityID,
|
||||
Time: point.Bucket.UTC(),
|
||||
}
|
||||
}
|
||||
applyRecordMetricValue(recordMap[key], metricName, point.Value)
|
||||
}
|
||||
}
|
||||
|
||||
records := make([]models.Record, 0, len(recordMap))
|
||||
for _, rec := range recordMap {
|
||||
records = append(records, *rec)
|
||||
}
|
||||
sortRecords(records)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func recordMetricAggregation(metricName string) metric.Aggregation {
|
||||
switch metricName {
|
||||
case MetricTrafficUp, MetricTrafficDown:
|
||||
return metric.AggSum
|
||||
case MetricNetTotalUp, MetricNetTotalDown:
|
||||
return metric.AggLast
|
||||
default:
|
||||
return metric.AggAvg
|
||||
}
|
||||
}
|
||||
|
||||
func recordSeriesInterval(s *metric.Store, start, end, now time.Time) time.Duration {
|
||||
interval := recordDownsampleInterval(end.Sub(start), 500)
|
||||
return s.CompatibleSeriesInterval(start, now, interval)
|
||||
}
|
||||
|
||||
func recordDownsampleInterval(rangeDuration time.Duration, maxPoints int) time.Duration {
|
||||
if maxPoints <= 0 {
|
||||
maxPoints = 500
|
||||
}
|
||||
nanos := rangeDuration.Nanoseconds()
|
||||
if nanos <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
interval := time.Duration((nanos + int64(maxPoints) - 1) / int64(maxPoints))
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return metric.FloorStandardInterval(interval)
|
||||
}
|
||||
|
||||
func listRecordEntityIDs(ctx context.Context, s *metric.Store, start, end time.Time, interval time.Duration) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
for _, metricName := range loadRecordMetricNames {
|
||||
ids, err := s.EntityIDs(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
Start: start.Add(-interval),
|
||||
End: end,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyRecordMetricValue(rec *models.Record, metricName string, value float64) {
|
||||
switch metricName {
|
||||
case MetricCPU:
|
||||
rec.Cpu = float32(value)
|
||||
case MetricGPU:
|
||||
rec.Gpu = float32(value)
|
||||
case MetricRAM:
|
||||
rec.Ram = int64(value)
|
||||
case MetricRAMTotal:
|
||||
rec.RamTotal = int64(value)
|
||||
case MetricSwap:
|
||||
rec.Swap = int64(value)
|
||||
case MetricSwapTotal:
|
||||
rec.SwapTotal = int64(value)
|
||||
case MetricLoad:
|
||||
rec.Load = float32(value)
|
||||
case MetricTemp:
|
||||
rec.Temp = float32(value)
|
||||
case MetricDisk:
|
||||
rec.Disk = int64(value)
|
||||
case MetricDiskTotal:
|
||||
rec.DiskTotal = int64(value)
|
||||
case MetricNetIn:
|
||||
rec.NetIn = int64(value)
|
||||
case MetricNetOut:
|
||||
rec.NetOut = int64(value)
|
||||
case MetricNetTotalUp:
|
||||
rec.NetTotalUp = int64(value)
|
||||
case MetricNetTotalDown:
|
||||
rec.NetTotalDown = int64(value)
|
||||
case MetricTrafficUp:
|
||||
rec.TrafficUp = int64(value)
|
||||
case MetricTrafficDown:
|
||||
rec.TrafficDown = int64(value)
|
||||
case MetricProcess:
|
||||
rec.Process = int(value)
|
||||
case MetricConnections:
|
||||
rec.Connections = int(value)
|
||||
case MetricConnectionsUDP:
|
||||
rec.ConnectionsUdp = int(value)
|
||||
}
|
||||
}
|
||||
|
||||
func sortRecords(records []models.Record) {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
if records[i].Client != records[j].Client {
|
||||
return records[i].Client < records[j].Client
|
||||
}
|
||||
return records[i].Time.Before(records[j].Time)
|
||||
})
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 从 metric store 查询 GPU 记录
|
||||
func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
// 查询 GPU 相关指标(每设备利用率使用独立指标 gpu.device.usage)
|
||||
gpuMetrics := []string{MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp}
|
||||
|
||||
// 按设备索引和时间组织数据
|
||||
type gpuKey struct {
|
||||
deviceIndex int
|
||||
timestamp int64
|
||||
}
|
||||
recordMap := make(map[gpuKey]*models.GPURecord)
|
||||
|
||||
for _, metricName := range gpuMetrics {
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: clientUUID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
continue // GPU 数据可能不存在
|
||||
}
|
||||
|
||||
for _, p := range points {
|
||||
deviceIndex := 0
|
||||
deviceName := ""
|
||||
if idx, ok := p.Tags["device_index"]; ok {
|
||||
fmt.Sscanf(idx, "%d", &deviceIndex)
|
||||
}
|
||||
if name, ok := p.Tags["device_name"]; ok {
|
||||
deviceName = name
|
||||
}
|
||||
|
||||
key := gpuKey{deviceIndex: deviceIndex, timestamp: p.Timestamp.Unix()}
|
||||
if recordMap[key] == nil {
|
||||
recordMap[key] = &models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: p.Timestamp.UTC(),
|
||||
DeviceIndex: deviceIndex,
|
||||
DeviceName: deviceName,
|
||||
}
|
||||
}
|
||||
rec := recordMap[key]
|
||||
|
||||
switch metricName {
|
||||
case MetricGPUDeviceUsage:
|
||||
rec.Utilization = float32(p.Value)
|
||||
case MetricGPUMem:
|
||||
rec.MemUsed = int64(p.Value)
|
||||
case MetricGPUMemTotal:
|
||||
rec.MemTotal = int64(p.Value)
|
||||
case MetricGPUTemp:
|
||||
rec.Temperature = int(p.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
records := make([]models.GPURecord, 0, len(recordMap))
|
||||
for _, rec := range recordMap {
|
||||
records = append(records, *rec)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetPingRecords 从 metric store 查询兼容旧接口的 ping 记录。
|
||||
//
|
||||
// 旧接口过去直接读取 ping_records 的原始点。启用 metric rollup 后,较旧
|
||||
// 的 raw 点会被压入 rollup 并删除,因此这里使用 Series 走与 queryMetrics
|
||||
// 相同的 raw/rollup 混合读取路径,并保留 task_id 标签。
|
||||
func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
query := metric.Query{
|
||||
MetricName: MetricPingLatency,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
}
|
||||
|
||||
if clientUUID != "" {
|
||||
query.EntityID = clientUUID
|
||||
}
|
||||
|
||||
if taskID >= 0 {
|
||||
query.Tags = map[string]string{"task_id": fmt.Sprintf("%d", taskID)}
|
||||
}
|
||||
|
||||
interval := pingQueryInterval(end.Sub(start), 4000)
|
||||
interval = s.CompatibleSeriesInterval(start, time.Now().UTC(), interval)
|
||||
points, err := s.Series(ctx, metric.AggregateQuery{
|
||||
Query: query,
|
||||
Aggregation: metric.AggLast,
|
||||
Interval: interval,
|
||||
PreserveSeries: true,
|
||||
}, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]models.PingRecord, 0, len(points))
|
||||
for _, p := range points {
|
||||
taskIDVal := uint(0)
|
||||
if tid, ok := p.Tags["task_id"]; ok {
|
||||
var t uint64
|
||||
fmt.Sscanf(tid, "%d", &t)
|
||||
taskIDVal = uint(t)
|
||||
}
|
||||
|
||||
records = append(records, models.PingRecord{
|
||||
Client: p.EntityID,
|
||||
TaskId: taskIDVal,
|
||||
Time: p.Bucket.UTC(),
|
||||
Value: int(p.Value),
|
||||
})
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].Time.After(records[j].Time)
|
||||
})
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func pingQueryInterval(rangeDuration time.Duration, maxPoints int) time.Duration {
|
||||
if maxPoints <= 0 {
|
||||
maxPoints = 4000
|
||||
}
|
||||
if rangeDuration <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
interval := time.Duration((rangeDuration.Nanoseconds() + int64(maxPoints) - 1) / int64(maxPoints))
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return metric.FloorStandardInterval(interval)
|
||||
}
|
||||
|
||||
// farFuture 返回一个足够远的未来时间,用于以 DeleteBefore 语义清空某指标的全部数据。
|
||||
func farFuture() time.Time {
|
||||
return time.Now().UTC().Add(24 * 365 * time.Hour)
|
||||
}
|
||||
|
||||
// DeleteAllRecords 删除所有负载/系统类记录(保留指标定义,不含 ping)。
|
||||
func DeleteAllRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
for _, metricName := range recordMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
log.Printf("Failed to delete metric %s: %v", metricName, err)
|
||||
}
|
||||
}
|
||||
clearReportTrafficStates()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllPingRecords 删除全部 ping 记录(保留指标定义)。
|
||||
func DeleteAllPingRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePingRecordsByTask 删除指定任务(task_id)的全部 ping 记录。
|
||||
func DeletePingRecordsByTask(ctx context.Context, taskIDs []uint) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, id := range taskIDs {
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteSeries(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
Tags: map[string]string{"task_id": fmt.Sprintf("%d", id)},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records for task %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEntity 删除指定 agent 在所有指标下的历史数据。
|
||||
func DeleteEntity(ctx context.Context, entityID string) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
if _, err := s.DeleteEntity(ctx, entityID); err != nil {
|
||||
return fmt.Errorf("failed to delete metric records for entity %s: %w", entityID, err)
|
||||
}
|
||||
deleteReportTrafficState(entityID)
|
||||
return nil
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
func TestDefaultRollupPolicy(t *testing.T) {
|
||||
policy := defaultRollupPolicy()
|
||||
if err := policy.Validate(); err != nil {
|
||||
t.Fatalf("default rollup policy should validate: %v", err)
|
||||
}
|
||||
if policy.RawRetention != DefaultRollupRawRetention {
|
||||
t.Fatalf("raw retention = %s, want %s", policy.RawRetention, DefaultRollupRawRetention)
|
||||
}
|
||||
if len(policy.Tiers) != 3 {
|
||||
t.Fatalf("expected 3 rollup tiers, got %d", len(policy.Tiers))
|
||||
}
|
||||
|
||||
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour}
|
||||
wantRetentions := []time.Duration{48 * time.Hour, 14 * 24 * time.Hour, 14 * 24 * time.Hour}
|
||||
for i := range wantIntervals {
|
||||
if policy.Tiers[i].Interval != wantIntervals[i] {
|
||||
t.Fatalf("tier %d interval = %s, want %s", i, policy.Tiers[i].Interval, wantIntervals[i])
|
||||
}
|
||||
if policy.Tiers[i].Retention != wantRetentions[i] {
|
||||
t.Fatalf("tier %d retention = %s, want %s", i, policy.Tiers[i].Retention, wantRetentions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
DownsamplingEnabled: true,
|
||||
TablePrefix: "metric_",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if !cfg.RollupPolicy.Enabled() {
|
||||
t.Fatal("expected default rollup policy to be enabled")
|
||||
}
|
||||
if cfg.RollupPolicy.RawRetention != DefaultRollupRawRetention {
|
||||
t.Fatalf("raw retention = %s, want %s", cfg.RollupPolicy.RawRetention, DefaultRollupRawRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
DownsamplingEnabled: true,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
wantRollupRetention := 14 * 24 * time.Hour
|
||||
lastTier := cfg.RollupPolicy.Tiers[len(cfg.RollupPolicy.Tiers)-1]
|
||||
if lastTier.Retention != wantRollupRetention {
|
||||
t.Fatalf("rollup retention = %s, want %s", lastTier.Retention, wantRollupRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigCanDisableDownsampling(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
DownsamplingEnabled: false,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if cfg.RollupPolicy.Enabled() {
|
||||
t.Fatal("expected rollup policy to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPingRecordsReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{
|
||||
Name: MetricPingLatency,
|
||||
Type: metric.TypeGauge,
|
||||
RetentionDays: 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("create ping metric: %v", err)
|
||||
}
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-20 * time.Minute), Value: 20, Tags: map[string]string{"task_id": "7"}},
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-10 * time.Minute), Value: 10, Tags: map[string]string{"task_id": "7"}},
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-5 * time.Minute), Value: 5, Tags: map[string]string{"task_id": "7"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("write ping points: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact ping points: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
}()
|
||||
|
||||
records, err := GetPingRecords(ctx, "node-a", 7, now.Add(-30*time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get ping records: %v", err)
|
||||
}
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("expected 3 ping records across raw and rollup data, got %d: %#v", len(records), records)
|
||||
}
|
||||
if records[0].Value != 5 || records[1].Value != 10 || records[2].Value != 20 {
|
||||
t.Fatalf("unexpected ping values in descending order: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *testing.T) {
|
||||
if defaultBuiltinMetricRetentionDays != 1 {
|
||||
t.Fatalf("default built-in metric retention = %d, want 1 day", defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create definitions: %v", err)
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list definitions: %v", err)
|
||||
}
|
||||
if len(defs) != 25 {
|
||||
t.Fatalf("definition count = %d, want 25", len(defs))
|
||||
}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays != defaultBuiltinMetricRetentionDays {
|
||||
t.Fatalf("%s retention = %d, want %d", def.Name, def.RetentionDays, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
cpu, err := s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("get cpu definition: %v", err)
|
||||
}
|
||||
cpu.RetentionDays = 60
|
||||
if err := s.UpsertMetric(ctx, cpu); err != nil {
|
||||
t.Fatalf("override cpu retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("recreate definitions: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload cpu definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 60 {
|
||||
t.Fatalf("cpu retention = %d, want preserved override 60", cpu.RetentionDays)
|
||||
}
|
||||
if _, err := s.SetMetricRetention(ctx, MetricCPU, 0); err != nil {
|
||||
t.Fatalf("disable cpu retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("refresh disabled definition: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload disabled cpu definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 0 {
|
||||
t.Fatalf("cpu retention = %d, want preserved disabled state", cpu.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsUsesLegacySpanOnlyForNewDefinitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 10); err != nil {
|
||||
t.Fatalf("create migration definitions: %v", err)
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list migration definitions: %v", err)
|
||||
}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays != 10 {
|
||||
t.Fatalf("%s retention = %d, want legacy span 10", def.Name, def.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
cpu, err := s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("get CPU definition: %v", err)
|
||||
}
|
||||
cpu.RetentionDays = 3
|
||||
if err := s.UpsertMetric(ctx, cpu); err != nil {
|
||||
t.Fatalf("override CPU retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 20); err != nil {
|
||||
t.Fatalf("refresh migration definitions: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload CPU definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 3 {
|
||||
t.Fatalf("existing CPU retention = %d, want preserved 3", cpu.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRetentionSummaryUsesAllMetricDefinitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
}()
|
||||
|
||||
empty, err := GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize empty store: %v", err)
|
||||
}
|
||||
if empty.AllPositive || empty.MaxDays != 0 {
|
||||
t.Fatalf("unexpected empty summary: %#v", empty)
|
||||
}
|
||||
for _, def := range []metric.Definition{
|
||||
{Name: "short", Type: metric.TypeGauge, RetentionDays: 7},
|
||||
{Name: "long", Type: metric.TypeGauge, RetentionDays: 60},
|
||||
} {
|
||||
if err := s.UpsertMetric(ctx, def); err != nil {
|
||||
t.Fatalf("upsert %s: %v", def.Name, err)
|
||||
}
|
||||
}
|
||||
summary, err := GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize definitions: %v", err)
|
||||
}
|
||||
if !summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if _, err := s.SetMetricRetention(ctx, "short", 0); err != nil {
|
||||
t.Fatalf("disable short metric: %v", err)
|
||||
}
|
||||
summary, err = GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize disabled metric: %v", err)
|
||||
}
|
||||
if summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected disabled summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRetentionDefinitionsRequiresEveryMetricToBePositive(t *testing.T) {
|
||||
summary := summarizeRetentionDefinitions([]metric.Definition{
|
||||
{Name: "enabled", RetentionDays: 30},
|
||||
{Name: "disabled", RetentionDays: 0},
|
||||
{Name: "long", RetentionDays: 60},
|
||||
})
|
||||
if summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{
|
||||
Name: "raw.metric",
|
||||
Type: metric.TypeGauge,
|
||||
RetentionDays: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert metric: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-48 * time.Hour), Value: 1},
|
||||
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-time.Hour), Value: 2},
|
||||
}); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if _, err := Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: "raw.metric",
|
||||
EntityID: "node",
|
||||
Start: now.Add(-72 * time.Hour),
|
||||
End: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query points: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != 2 {
|
||||
t.Fatalf("expected only the retained raw point, got %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactKeepsRotatingCursorAfterFullCycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.metric", "b.metric", "c.metric"} {
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge}); err != nil {
|
||||
t.Fatalf("upsert metric %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 1
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if _, err := Compact(ctx, time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
if compactAt != 1 {
|
||||
t.Fatalf("compact cursor = %d, want 1 after a complete rotated cycle", compactAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
ts := now.Add(-time.Hour)
|
||||
rec := models.Record{
|
||||
Client: "node-a",
|
||||
Time: ts,
|
||||
Cpu: 42.5,
|
||||
Ram: 123456,
|
||||
RamTotal: 999999,
|
||||
Disk: 456789,
|
||||
DiskTotal: 777777,
|
||||
Load: 0.75,
|
||||
Connections: 321,
|
||||
ConnectionsUdp: 12,
|
||||
}
|
||||
if _, err := WriteReport(ctx, v1.Report{
|
||||
UUID: rec.Client,
|
||||
UpdatedAt: ts,
|
||||
CPU: v1.CPUReport{Usage: float64(rec.Cpu)},
|
||||
Ram: v1.RamReport{Used: rec.Ram, Total: rec.RamTotal},
|
||||
Load: v1.LoadReport{Load1: float64(rec.Load)},
|
||||
Disk: v1.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
|
||||
Process: rec.Process,
|
||||
Connections: v1.ConnectionsReport{
|
||||
TCP: rec.Connections,
|
||||
UDP: rec.ConnectionsUdp,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact raw into rollup: %v", err)
|
||||
}
|
||||
raw, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: rec.Client,
|
||||
Start: ts.Add(-time.Minute),
|
||||
End: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query raw cpu: %v", err)
|
||||
}
|
||||
if len(raw) != 0 {
|
||||
t.Fatalf("expected old raw cpu point to be deleted after compaction, got %d", len(raw))
|
||||
}
|
||||
|
||||
got, err := GetRecordsByClientAndTime(ctx, rec.Client, ts.Add(-time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get records: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 reconstructed record from rollup, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Cpu == 0 || got[0].Ram == 0 || got[0].Disk == 0 || got[0].Connections == 0 {
|
||||
t.Fatalf("record was not reconstructed from rollup: %#v", got[0])
|
||||
}
|
||||
|
||||
all, err := GetRecordsByTime(ctx, ts.Add(-time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get all records: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].Client != rec.Client || all[0].Cpu == 0 {
|
||||
t.Fatalf("all-client records were not reconstructed from rollup: %#v", all)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
)
|
||||
|
||||
// migration.go
|
||||
//
|
||||
// 启动阶段的 metrics 数据迁移。这里只处理 metrics 存储后端切换时的数据搬运
|
||||
// (例如默认 SQLite ./data/metrics.db 切换到 MySQL/PostgreSQL)。旧 komari.db
|
||||
// 监控表导入属于一次性迁移,见 pkg/migrations.RunMetricStoreMigrations。
|
||||
|
||||
// targetFingerprint 返回当前 metrics 目标库的指纹(driver + 归一化 DSN),
|
||||
// 用于判断 metrics 存储后端是否发生变化(例如从 SQLite 切换到 MySQL/PostgreSQL)。
|
||||
func targetFingerprint(cfg *MetricStoreConfig) string {
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
dsn := strings.TrimSpace(cfg.DSN)
|
||||
return fmt.Sprintf("%s|%s", driver, dsn)
|
||||
}
|
||||
|
||||
// RunStartupMigration 在服务启动时检查 metrics 存储后端是否发生变化,
|
||||
// 若变化则把上一个 metrics 目标库的全部数据搬运到当前目标。
|
||||
//
|
||||
// 判定依据 MigrationTargetKey(记录“数据当前完整所在的 metrics 目标指纹”):
|
||||
// - saved == current:数据已在当前目标,无需处理。
|
||||
// - saved == "":无历史目标记录。老快照的 metrics 数据固定落在默认 SQLite
|
||||
// (./data/metrics.db);若当前目标不是默认 SQLite(例如已切到 MySQL/PostgreSQL),
|
||||
// 把默认 SQLite 中可能存在的历史数据搬运过来,否则仅登记指纹。
|
||||
// - saved != current:后端已切换,把上一个目标(saved)的数据搬运到当前目标。
|
||||
//
|
||||
// 搬运以 upsert 写入,幂等,中断后重启可安全重跑。任一失败都返回错误,
|
||||
// 调用方应让启动失败并打印明确错误。
|
||||
func RunStartupMigration() error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
|
||||
current := targetFingerprint(cfg)
|
||||
saved, _ := config.GetAs[string](MigrationTargetKey, "")
|
||||
|
||||
// 数据已完整位于当前目标:无需搬运。
|
||||
if saved == current {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 上一个目标:优先使用已保存指纹;无记录时按老快照默认 SQLite 推断。
|
||||
prev := saved
|
||||
if prev == "" {
|
||||
prev = defaultSQLiteFingerprint()
|
||||
}
|
||||
|
||||
if prev != current {
|
||||
if err := migrateFromPreviousStore(prev, cfg, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Set(MigrationTargetKey, current); err != nil {
|
||||
log.Printf("Failed to persist migration target fingerprint: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// storeMigrationWindow 是 store-to-store 迁移时按时间分窗查询的窗口大小。
|
||||
// 采样点按时间窗口分批拉取,避免一次性把整段序列读入内存。
|
||||
const storeMigrationWindow = 6 * time.Hour
|
||||
|
||||
// configFromFingerprint 从目标指纹(driver|dsn)重建一个 MetricStoreConfig,
|
||||
// 用于以只读方式打开“上一次的 metrics 目标库”。表前缀、保留天数、连接数等
|
||||
// 沿用当前配置(切换后端时这些通常不变)。
|
||||
func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*MetricStoreConfig, error) {
|
||||
idx := strings.Index(fingerprint, "|")
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("invalid target fingerprint: %q", fingerprint)
|
||||
}
|
||||
driver := fingerprint[:idx]
|
||||
dsn := fingerprint[idx+1:]
|
||||
if strings.TrimSpace(driver) == "" {
|
||||
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
|
||||
}
|
||||
return &MetricStoreConfig{
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
DownsamplingEnabled: base.DownsamplingEnabled,
|
||||
TablePrefix: base.TablePrefix,
|
||||
MaxOpenConns: base.MaxOpenConns,
|
||||
MaxIdleConns: base.MaxIdleConns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openSourceStore 打开一个已存在的 metrics 目标库作为数据搬运的源库读取。
|
||||
//
|
||||
// 使用 autoMigrate=true:GORM 的 AutoMigrate 只新增表/列/索引、从不删除数据,
|
||||
// 因此对真实旧库幂等无害;而当源库文件/表不存在(例如老快照记录了 completed
|
||||
// 但 metrics.db 缺失)时,可创建空表让后续 ListMetrics 返回空集而非报
|
||||
// "no such table",从而把“无历史可迁移”识别为正常情况而非错误。
|
||||
func openSourceStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metric.Open(ctx, metricCfg)
|
||||
}
|
||||
|
||||
// defaultSQLiteFingerprint 返回默认 SQLite metrics 库(./data/metrics.db)的目标指纹。
|
||||
// 老快照的 metrics 数据固定落在该 SQLite 文件,用于在缺失指纹时推断上一个源库。
|
||||
func defaultSQLiteFingerprint() string {
|
||||
return targetFingerprint(&MetricStoreConfig{Driver: "sqlite", DSN: "./data/metrics.db"})
|
||||
}
|
||||
|
||||
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
|
||||
// 把其中的全部指标搬运到当前目标 dst(例如 SQLite metrics.db → MySQL/PostgreSQL)。
|
||||
//
|
||||
// 该过程幂等:dst 以 (metric, entity, tags, ts) upsert 写入,中断后重启可安全重跑。
|
||||
func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, dst *metric.Store) error {
|
||||
prevCfg, err := configFromFingerprint(prevFingerprint, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse previous metrics target %q: %w", prevFingerprint, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
src, err := openSourceStore(ctx, prevCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open previous metrics store (%s): %w", prevFingerprint, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
log.Printf("Migrating metrics from previous store %s to current target...", prevFingerprint)
|
||||
total, err := MigrateBetweenStores(ctx, src, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store-to-store metrics migration failed: %w", err)
|
||||
}
|
||||
log.Printf("Store-to-store metrics migration completed (%d points)", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
|
||||
// - currentMetric:当前正在搬运的指标名。
|
||||
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
|
||||
// - totalMetrics:指标定义总数。
|
||||
// - addedPoints:本次新写入目标库的采样点数(用于外部累计)。
|
||||
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
|
||||
|
||||
// MigrateBetweenStores 把 src metric store 的全部指标定义与采样点搬运到 dst。
|
||||
//
|
||||
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
|
||||
// - 先在 dst 建立/更新全部指标定义;
|
||||
// - 再按指标、按时间窗口分批读取源库采样点并写入 dst。
|
||||
//
|
||||
// 采样点在 dst 以 (metric_name, entity_id, tags, ts) upsert 写入,窗口边界重叠也
|
||||
// 幂等,因此整个过程可安全重试。返回搬运的采样点总数。
|
||||
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
|
||||
return migrateBetweenStores(ctx, src, dst, nil)
|
||||
}
|
||||
|
||||
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
|
||||
// 观察者 observe(为 nil 时行为与旧版本完全一致)。启动迁移走 nil,WebUI/API 触发
|
||||
// 的迁移传入回调以实时更新进度。
|
||||
func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe storeMigrationObserver) (int64, error) {
|
||||
if src == nil || dst == nil {
|
||||
return 0, fmt.Errorf("source or destination metric store is nil")
|
||||
}
|
||||
|
||||
defs, err := src.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list source metrics: %w", err)
|
||||
}
|
||||
|
||||
var total int64
|
||||
for i, def := range defs {
|
||||
if observe != nil {
|
||||
// 进入下一个指标:已完成 i 个指标。
|
||||
observe(def.Name, i, len(defs), 0)
|
||||
}
|
||||
// 目标库先建立指标定义,保证后续写入的指标存在。
|
||||
if err := dst.UpsertMetric(ctx, def); err != nil {
|
||||
return total, fmt.Errorf("upsert metric %q on target: %w", def.Name, err)
|
||||
}
|
||||
if def.RetentionDays == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
earliest, latest, ok, err := metricTimeBounds(ctx, src, def.Name)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("resolve time bounds for metric %q: %w", def.Name, err)
|
||||
}
|
||||
if !ok {
|
||||
// 该指标只有定义没有数据,跳过。
|
||||
continue
|
||||
}
|
||||
|
||||
var migrated int64
|
||||
for windowStart := earliest; !windowStart.After(latest); windowStart = windowStart.Add(storeMigrationWindow) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return total, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
windowEnd := windowStart.Add(storeMigrationWindow)
|
||||
points, err := src.Query(ctx, metric.Query{
|
||||
MetricName: def.Name,
|
||||
Start: windowStart,
|
||||
End: windowEnd,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("query metric %q window [%s,%s): %w", def.Name, windowStart, windowEnd, err)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := dst.WriteBatch(ctx, points); err != nil {
|
||||
return total, fmt.Errorf("write metric %q batch to target: %w", def.Name, err)
|
||||
}
|
||||
migrated += int64(len(points))
|
||||
total += int64(len(points))
|
||||
if observe != nil {
|
||||
observe(def.Name, i, len(defs), int64(len(points)))
|
||||
}
|
||||
}
|
||||
if migrated > 0 {
|
||||
log.Printf("[store-migration] metric %q: migrated %d points", def.Name, migrated)
|
||||
}
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// metricTimeBounds 返回某指标在 src 中最早/最晚采样时间。ok=false 表示无数据。
|
||||
// 通过升/降序各取一条采样点定位边界,避免把整段序列读入内存。
|
||||
func metricTimeBounds(ctx context.Context, src *metric.Store, name string) (time.Time, time.Time, bool, error) {
|
||||
wide := metric.Query{
|
||||
MetricName: name,
|
||||
Start: time.Unix(0, 0),
|
||||
End: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
asc := wide
|
||||
asc.Order = metric.OrderAsc
|
||||
asc.Limit = 1
|
||||
first, err := src.Query(ctx, asc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false, err
|
||||
}
|
||||
if len(first) == 0 {
|
||||
return time.Time{}, time.Time{}, false, nil
|
||||
}
|
||||
|
||||
desc := wide
|
||||
desc.Order = metric.OrderDesc
|
||||
desc.Limit = 1
|
||||
last, err := src.Query(ctx, desc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false, err
|
||||
}
|
||||
if len(last) == 0 {
|
||||
return time.Time{}, time.Time{}, false, nil
|
||||
}
|
||||
|
||||
return first[0].Timestamp, last[0].Timestamp, true, nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import "context"
|
||||
|
||||
// storeOperationGate serializes operations that need a stable active Store.
|
||||
// A channel-backed gate supports both non-blocking scheduled work and
|
||||
// context-aware admin/shutdown waits.
|
||||
type storeOperationGate struct {
|
||||
token chan struct{}
|
||||
}
|
||||
|
||||
func newStoreOperationGate() *storeOperationGate {
|
||||
gate := &storeOperationGate{token: make(chan struct{}, 1)}
|
||||
gate.token <- struct{}{}
|
||||
return gate
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case <-g.token:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) TryAcquire() bool {
|
||||
select {
|
||||
case <-g.token:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) Release() {
|
||||
g.token <- struct{}{}
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
type reportTrafficState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
reportTrafficValues
|
||||
}
|
||||
|
||||
type reportTrafficValues struct {
|
||||
initialized bool
|
||||
timestamp time.Time
|
||||
hasUp bool
|
||||
totalUp int64
|
||||
hasDown bool
|
||||
totalDown int64
|
||||
}
|
||||
|
||||
var reportTrafficStates sync.Map
|
||||
|
||||
const (
|
||||
reportBatchInterval = 3 * time.Second
|
||||
reportBatchQueueSize = 4096
|
||||
reportBatchMaxReports = 256 * 21
|
||||
reportBatchWriteTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
reportBatcherMu sync.Mutex
|
||||
reportBatcher *reportBatchWorker
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReportBatchQueueFull = errors.New("metric report batch queue is full")
|
||||
ErrReportBatchStopped = errors.New("metric report batcher is stopped")
|
||||
)
|
||||
|
||||
type reportBatchRequest struct {
|
||||
ctx context.Context
|
||||
done chan error
|
||||
stop bool
|
||||
}
|
||||
|
||||
type reportBatchWorker struct {
|
||||
mu sync.Mutex
|
||||
queue chan v1.Report
|
||||
requests chan reportBatchRequest
|
||||
done chan struct{}
|
||||
stopping bool
|
||||
}
|
||||
|
||||
// StartReportBatcher starts the shared report writer. Reports are collected for
|
||||
// a short window so many agents can be persisted in one database transaction.
|
||||
func StartReportBatcher() {
|
||||
reportBatcherMu.Lock()
|
||||
defer reportBatcherMu.Unlock()
|
||||
if reportBatcher != nil {
|
||||
return
|
||||
}
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v1.Report, reportBatchQueueSize),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
reportBatcher = worker
|
||||
go worker.run()
|
||||
}
|
||||
|
||||
// StopReportBatcher stops the report writer after flushing all queued reports.
|
||||
func StopReportBatcher(ctx context.Context) error {
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
if worker == nil {
|
||||
reportBatcherMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
worker.mu.Lock()
|
||||
worker.stopping = true
|
||||
worker.mu.Unlock()
|
||||
reportBatcherMu.Unlock()
|
||||
|
||||
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1), stop: true}
|
||||
select {
|
||||
case worker.requests <- request:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case err := <-request.done:
|
||||
<-worker.done
|
||||
reportBatcherMu.Lock()
|
||||
if reportBatcher == worker {
|
||||
reportBatcher = nil
|
||||
}
|
||||
reportBatcherMu.Unlock()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// FlushReportBatch synchronously flushes the current queue. It is useful for
|
||||
// controlled handoff points and deterministic tests; normal operation relies
|
||||
// on the three-second ticker.
|
||||
func FlushReportBatch(ctx context.Context) error {
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker == nil {
|
||||
return nil
|
||||
}
|
||||
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1)}
|
||||
worker.mu.Lock()
|
||||
stopping := worker.stopping
|
||||
worker.mu.Unlock()
|
||||
if stopping {
|
||||
return ErrReportBatchStopped
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request:
|
||||
case <-worker.done:
|
||||
return ErrReportBatchStopped
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case err := <-request.done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReport persists one agent report as raw metric points sharing the same
|
||||
// server receive time. Traffic deltas are derived from the previous counters
|
||||
// and stored alongside the raw counters so they remain summable after rollup.
|
||||
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
|
||||
if report.UUID == "" {
|
||||
return v1.Report{}, fmt.Errorf("report UUID is required")
|
||||
}
|
||||
if report.UpdatedAt.IsZero() {
|
||||
return v1.Report{}, fmt.Errorf("report receive time is required")
|
||||
}
|
||||
report.UpdatedAt = report.UpdatedAt.UTC()
|
||||
if GetStore() == nil {
|
||||
return v1.Report{}, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker != nil {
|
||||
if err := worker.enqueue(ctx, report); err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
saved, err := writeReportBatch(ctx, []v1.Report{report})
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return saved[0], nil
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) enqueue(ctx context.Context, report v1.Report) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopping {
|
||||
return ErrReportBatchStopped
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case w.queue <- report:
|
||||
return nil
|
||||
default:
|
||||
return ErrReportBatchQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) run() {
|
||||
ticker := time.NewTicker(reportBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var pending []v1.Report
|
||||
for {
|
||||
select {
|
||||
case report := <-w.queue:
|
||||
pending = append(pending, report)
|
||||
case request := <-w.requests:
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchMaxReports-len(pending))...)
|
||||
err := writePendingReports(request.ctx, &pending)
|
||||
if request.stop {
|
||||
if err != nil {
|
||||
log.Printf("failed to flush metric report batch during shutdown: %v", err)
|
||||
}
|
||||
close(w.done)
|
||||
request.done <- err
|
||||
return
|
||||
}
|
||||
request.done <- err
|
||||
case <-ticker.C:
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchMaxReports-len(pending))...)
|
||||
if err := writePendingReports(context.Background(), &pending); err != nil {
|
||||
log.Printf("failed to flush metric report batch: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
reports := make([]v1.Report, 0, limit)
|
||||
for len(reports) < limit {
|
||||
select {
|
||||
case report := <-queue:
|
||||
reports = append(reports, report)
|
||||
default:
|
||||
return reports
|
||||
}
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
|
||||
if len(*pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
|
||||
defer cancel()
|
||||
if err := func() error {
|
||||
reports := *pending
|
||||
_, err := writeReportBatch(writeCtx, reports)
|
||||
return err
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
*pending = (*pending)[:0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
|
||||
if len(reports) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return nil, fmt.Errorf("wait for metric store operation before writing reports: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
prepared := make([]v1.Report, len(reports))
|
||||
copy(prepared, reports)
|
||||
points := make([]metric.Point, 0, len(reports)*20)
|
||||
pendingStates := make(map[*reportTrafficState]reportTrafficValues)
|
||||
for i, report := range prepared {
|
||||
stateValue, _ := reportTrafficStates.LoadOrStore(report.UUID, &reportTrafficState{})
|
||||
state := stateValue.(*reportTrafficState)
|
||||
values, ok := pendingStates[state]
|
||||
if !ok {
|
||||
state.mu.Lock()
|
||||
values = state.reportTrafficValues
|
||||
state.mu.Unlock()
|
||||
}
|
||||
if !values.initialized {
|
||||
var err error
|
||||
values.totalUp, values.hasUp, err = latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load previous upload counter: %w", err)
|
||||
}
|
||||
values.totalDown, values.hasDown, err = latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load previous download counter: %w", err)
|
||||
}
|
||||
values.initialized = true
|
||||
}
|
||||
|
||||
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
|
||||
report.UpdatedAt = values.timestamp.Add(time.Nanosecond)
|
||||
}
|
||||
trafficUp := int64(0)
|
||||
if values.hasUp {
|
||||
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
|
||||
}
|
||||
trafficDown := int64(0)
|
||||
if values.hasDown {
|
||||
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
|
||||
}
|
||||
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
|
||||
values.timestamp = report.UpdatedAt
|
||||
values.hasUp = true
|
||||
values.totalUp = report.Network.TotalUp
|
||||
values.hasDown = true
|
||||
values.totalDown = report.Network.TotalDown
|
||||
pendingStates[state] = values
|
||||
prepared[i] = report
|
||||
}
|
||||
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for state, values := range pendingStates {
|
||||
state.mu.Lock()
|
||||
state.reportTrafficValues = values
|
||||
state.mu.Unlock()
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
|
||||
entityID := report.UUID
|
||||
ts := report.UpdatedAt
|
||||
gpuUsage := 0.0
|
||||
if report.GPU != nil {
|
||||
gpuUsage = report.GPU.AverageUsage
|
||||
}
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
|
||||
{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: gpuUsage},
|
||||
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
|
||||
{MetricName: MetricRAMTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Total)},
|
||||
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
|
||||
{MetricName: MetricSwapTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Total)},
|
||||
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
|
||||
{MetricName: MetricTemp, EntityID: entityID, Timestamp: ts, Value: 0},
|
||||
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
|
||||
{MetricName: MetricDiskTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Total)},
|
||||
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
|
||||
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
|
||||
{MetricName: MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalDown)},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(trafficUp)},
|
||||
{MetricName: MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(trafficDown)},
|
||||
{MetricName: MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(report.Process)},
|
||||
{MetricName: MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.TCP)},
|
||||
{MetricName: MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.UDP)},
|
||||
}
|
||||
if report.GPU == nil {
|
||||
return points
|
||||
}
|
||||
for deviceIndex, gpu := range report.GPU.DetailedInfo {
|
||||
tags := map[string]string{
|
||||
"device_index": strconv.Itoa(deviceIndex),
|
||||
"device_name": gpu.Name,
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{MetricName: MetricGPUMem, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryUsed), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUMemTotal, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryTotal), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUDeviceUsage, EntityID: entityID, Timestamp: ts, Value: gpu.Utilization, Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUTemp, EntityID: entityID, Timestamp: ts, Value: float64(gpu.Temperature), Tags: tags},
|
||||
)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
|
||||
def, err := s.GetMetric(ctx, metricName)
|
||||
if errors.Is(err, metric.ErrNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if def.RetentionDays <= 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
end := before.UTC().Add(-time.Nanosecond)
|
||||
start := end.Add(-time.Duration(def.RetentionDays) * 24 * time.Hour)
|
||||
interval := s.CompatibleSeriesInterval(start, before, 24*time.Hour)
|
||||
points, err := s.Series(ctx, metric.AggregateQuery{
|
||||
Query: metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
},
|
||||
Aggregation: metric.AggLast,
|
||||
Interval: interval,
|
||||
PreserveSeries: true,
|
||||
}, before)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(points[len(points)-1].Value), true, nil
|
||||
}
|
||||
|
||||
// GetLatestTrafficBefore returns the latest retained upload/download counters
|
||||
// before a boundary, transparently reading raw points or rollups.
|
||||
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
result := make(map[string]models.Record, len(entityIDs))
|
||||
for _, entityID := range entityIDs {
|
||||
if entityID == "" {
|
||||
continue
|
||||
}
|
||||
up, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
down, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasUp && !hasDown {
|
||||
continue
|
||||
}
|
||||
result[entityID] = models.Record{
|
||||
Client: entityID,
|
||||
Time: before.UTC().Add(-time.Nanosecond),
|
||||
NetTotalUp: up,
|
||||
NetTotalDown: down,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TrafficCounterDelta returns a reset-aware increase between two cumulative
|
||||
// traffic counters. After a reset, the current counter is the new increase.
|
||||
func TrafficCounterDelta(current, previous int64) int64 {
|
||||
if current < 0 || previous < 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= previous {
|
||||
return current - previous
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func deleteReportTrafficState(entityID string) {
|
||||
reportTrafficStates.Delete(entityID)
|
||||
}
|
||||
|
||||
func clearReportTrafficStates() {
|
||||
reportTrafficStates.Range(func(key, _ any) bool {
|
||||
reportTrafficStates.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
opts := []metric.Option{metric.WithMaxOpenConns(1)}
|
||||
if policy != nil {
|
||||
opts = append(opts, metric.WithRollupPolicy(*policy))
|
||||
}
|
||||
dsn := fmt.Sprintf("file:report-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn, opts...))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
_ = s.Close()
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
clearReportTrafficStates()
|
||||
t.Cleanup(func() {
|
||||
clearReportTrafficStates()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWriteReportStoresRawMetricsAndResetAwareTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
s := useReportTestStore(t, &policy)
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
base := now.Add(-30 * time.Minute)
|
||||
|
||||
report := v1.Report{
|
||||
UUID: "node-a",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 12.5},
|
||||
Ram: v1.RamReport{Used: 100, Total: 1000},
|
||||
Swap: v1.RamReport{Used: 20, Total: 200},
|
||||
Load: v1.LoadReport{Load1: 0.5},
|
||||
Disk: v1.DiskReport{Used: 300, Total: 3000},
|
||||
Network: v1.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
|
||||
Process: 7,
|
||||
Connections: v1.ConnectionsReport{TCP: 8, UDP: 9},
|
||||
GPU: &v1.GPUDetailReport{
|
||||
AverageUsage: 25,
|
||||
DetailedInfo: []v1.GPUDeviceInfo{{
|
||||
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Network.TotalUp = 150
|
||||
report.Network.TotalDown = 260
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write second report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Network.TotalUp = 20
|
||||
report.Network.TotalDown = 30
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write reset report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 20})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 30})
|
||||
assertMetricValues(t, s, MetricNetTotalUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{100, 150, 20})
|
||||
|
||||
gpuPoints, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPUDeviceUsage,
|
||||
EntityID: report.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Tags: map[string]string{"device_index": "0"},
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU points: %v", err)
|
||||
}
|
||||
if len(gpuPoints) != 3 || gpuPoints[0].Timestamp != base || gpuPoints[0].Tags["device_name"] != "GPU 0" {
|
||||
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
|
||||
}
|
||||
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact reports: %v", err)
|
||||
}
|
||||
deleteReportTrafficState(report.UUID)
|
||||
report.UpdatedAt = now
|
||||
report.Network.TotalUp = 35
|
||||
report.Network.TotalDown = 50
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write after restoring rollup baseline: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{15})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
|
||||
}
|
||||
|
||||
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
first := v1.Report{
|
||||
UUID: "batched-node",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(3 * time.Second)
|
||||
second.CPU.Usage = 20
|
||||
second.Network.TotalUp = 150
|
||||
second.Network.TotalDown = 260
|
||||
|
||||
if _, err := WriteReport(ctx, first); err != nil {
|
||||
t.Fatalf("queue first report: %v", err)
|
||||
}
|
||||
if _, err := WriteReport(ctx, second); err != nil {
|
||||
t.Fatalf("queue second report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: first.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query before flush: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued reports were written before flush: %#v", points)
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush report batch: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50})
|
||||
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
|
||||
}
|
||||
|
||||
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute)
|
||||
entityID := "node-aggregation"
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 30},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 100},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 200},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 20},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
records, err := GetRecordsByClientAndTime(ctx, entityID, base, base.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct records: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v, want one bucket", records)
|
||||
}
|
||||
if records[0].Cpu != 20 || records[0].NetTotalUp != 200 || records[0].TrafficUp != 30 {
|
||||
t.Fatalf("unexpected aggregation result: %#v", records[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter", current: 250, previous: 200, want: 50},
|
||||
{name: "unchanged counter", current: 100, previous: 100, want: 0},
|
||||
{name: "counter reset", current: 15, previous: 250, want: 15},
|
||||
{name: "negative current", current: -1, previous: 100, want: 0},
|
||||
{name: "negative previous", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := TrafficCounterDelta(test.current, test.previous); got != test.want {
|
||||
t.Fatalf("TrafficCounterDelta(%d, %d) = %d, want %d", test.current, test.previous, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
local := time.FixedZone("UTC+8", 8*60*60)
|
||||
receiveTime := time.Date(2026, 7, 17, 9, 30, 0, 123456789, local)
|
||||
report := v1.Report{
|
||||
UUID: "utc-report",
|
||||
UpdatedAt: receiveTime,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 1, TotalDown: 2},
|
||||
}
|
||||
|
||||
saved, err := WriteReport(ctx, report)
|
||||
if err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
if !saved.UpdatedAt.Equal(receiveTime) || saved.UpdatedAt.Location() != time.UTC {
|
||||
t.Fatalf("saved receive time = %s (%s), want UTC", saved.UpdatedAt, saved.UpdatedAt.Location())
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: report.UUID,
|
||||
Start: receiveTime.Add(-time.Nanosecond),
|
||||
End: receiveTime.Add(time.Nanosecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query stored point: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.Nanosecond() != 123456789 {
|
||||
t.Fatalf("stored points = %#v, want one UTC nanosecond point", points)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, want []float64) {
|
||||
t.Helper()
|
||||
points, err := s.Query(context.Background(), metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != len(want) {
|
||||
t.Fatalf("%s point count = %d, want %d: %#v", metricName, len(points), len(want), points)
|
||||
}
|
||||
for i := range want {
|
||||
if points[i].Value != want[i] {
|
||||
t.Fatalf("%s point %d = %v, want %v", metricName, i, points[i].Value, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// store_migration.go
|
||||
//
|
||||
// 面向 WebUI/API 的“存储后端迁移”支撑:把一个 metrics 源库(默认本地 SQLite
|
||||
// ./data/metrics.db)中的全部指标数据搬运到当前正在运行的 metrics 目标库
|
||||
// (通常是用户新配置并热重载后的 MySQL/PostgreSQL)。
|
||||
//
|
||||
// 与启动迁移(RunStartupMigration)的区别:
|
||||
// - 启动迁移在进程启动时自动、同步执行(komari.db → 当前 metrics 目标,或上一个
|
||||
// metrics 目标 → 当前目标)。
|
||||
// - 此处的 store 迁移由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可
|
||||
// 查询进度 / 取消。数据以 upsert 写入,可安全重复执行。
|
||||
//
|
||||
// 典型流程:
|
||||
// 1. 管理员在设置中把 metric_db_dsn 改为 MySQL/PostgreSQL,保存 → 后端连接测试
|
||||
// 通过后热重载,当前 store 切到远端(此时远端为空)。
|
||||
// 2. 管理员调用 startMetricMigration(不带 source 参数),系统以上一个 metrics
|
||||
// 目标(默认 SQLite metrics.db)为源,把历史数据搬运到远端。
|
||||
// 3. 完成后登记目标指纹,下次启动不再重复搬运。
|
||||
|
||||
// StoreMigrationProgress 描述一次 store-to-store 迁移的实时进度。
|
||||
type StoreMigrationProgress struct {
|
||||
Status string `json:"status"` // idle, running, completed, failed, canceled
|
||||
SourceDriver string `json:"source_driver"` // 源库驱动
|
||||
SourceDSN string `json:"source_dsn"` // 源库 DSN(脱敏后)
|
||||
TargetDriver string `json:"target_driver"` // 目标库驱动
|
||||
TargetDSN string `json:"target_dsn"` // 目标库 DSN(脱敏后)
|
||||
TotalMetrics int `json:"total_metrics"` // 指标定义总数
|
||||
CurrentMetric string `json:"current_metric"` // 当前正在搬运的指标名
|
||||
MetricsDone int `json:"metrics_done"` // 已完成的指标数
|
||||
MigratedPoints int64 `json:"migrated_points"` // 已搬运的采样点数
|
||||
StartTime time.Time `json:"start_time"` // 开始时间
|
||||
EndTime time.Time `json:"end_time"` // 结束时间
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
var (
|
||||
storeMigMu sync.Mutex
|
||||
storeMigProgress StoreMigrationProgress
|
||||
storeMigCancel context.CancelFunc
|
||||
storeMigDone chan struct{}
|
||||
storeClosing bool
|
||||
)
|
||||
|
||||
// IsStoreMigrationRunning 报告是否有 store-to-store 迁移正在运行。
|
||||
func IsStoreMigrationRunning() bool {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeMigCancel != nil
|
||||
}
|
||||
|
||||
// GetStoreMigrationProgress 返回当前 store-to-store 迁移进度快照。
|
||||
func GetStoreMigrationProgress() StoreMigrationProgress {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeMigProgress
|
||||
}
|
||||
|
||||
// ResolveStoreMigrationSourceFingerprint 推断本次 store 迁移的源库指纹。
|
||||
//
|
||||
// 优先级:
|
||||
// 1. 显式传入的 driver+dsn(driver 可留空,交由 DSN 推断)。
|
||||
// 2. 已保存的上一个 metrics 目标指纹(MigrationTargetKey)。
|
||||
// 3. 默认 SQLite(./data/metrics.db)。
|
||||
func ResolveStoreMigrationSourceFingerprint(driver, dsn string) string {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn != "" {
|
||||
d := ResolveDriverFromConfig(driver, dsn)
|
||||
return fmt.Sprintf("%s|%s", d, dsn)
|
||||
}
|
||||
if saved, _ := config.GetAs[string](MigrationTargetKey, ""); saved != "" {
|
||||
return saved
|
||||
}
|
||||
return defaultSQLiteFingerprint()
|
||||
}
|
||||
|
||||
// StartStoreMigration 异步启动一次 store-to-store 迁移:把源库(由 sourceDriver +
|
||||
// sourceDSN 指定,留空则自动推断)的全部指标数据搬运到当前运行中的 metrics 目标库。
|
||||
//
|
||||
// 返回错误表示“未能启动”(例如已有迁移在跑、源与目标相同、目标未初始化等);
|
||||
// 迁移过程中的错误通过 GetStoreMigrationProgress().Status == "failed" 与 Error 暴露。
|
||||
func StartStoreMigration(sourceDriver, sourceDSN string) error {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
targetFP := targetFingerprint(cfg)
|
||||
sourceFP := ResolveStoreMigrationSourceFingerprint(sourceDriver, sourceDSN)
|
||||
|
||||
if sourceFP == targetFP {
|
||||
return fmt.Errorf("source and target metrics database are the same; nothing to migrate")
|
||||
}
|
||||
|
||||
srcCfg, err := configFromFingerprint(sourceFP, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve source metrics store: %w", err)
|
||||
}
|
||||
if !storeOperations.TryAcquire() {
|
||||
return ErrStoreBusy
|
||||
}
|
||||
exclusiveLeaseHandedOff := false
|
||||
defer func() {
|
||||
if !exclusiveLeaseHandedOff {
|
||||
storeOperations.Release()
|
||||
}
|
||||
}()
|
||||
|
||||
storeMu.RLock()
|
||||
dst := store
|
||||
activeFingerprint := storeFingerprint
|
||||
storeMu.RUnlock()
|
||||
if dst == nil {
|
||||
return ErrStoreNotInitialized
|
||||
}
|
||||
if activeFingerprint != targetFP {
|
||||
return fmt.Errorf("active metric store does not match the configured migration target; wait for hot reload and retry")
|
||||
}
|
||||
|
||||
storeMigMu.Lock()
|
||||
if storeClosing {
|
||||
storeMigMu.Unlock()
|
||||
return ErrStoreBusy
|
||||
}
|
||||
if storeMigCancel != nil {
|
||||
storeMigMu.Unlock()
|
||||
return fmt.Errorf("a store migration is already in progress")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
storeMigCancel = cancel
|
||||
storeMigDone = done
|
||||
storeMigProgress = StoreMigrationProgress{
|
||||
Status: "running",
|
||||
SourceDriver: string(ResolveDriverFromConfig(srcCfg.Driver, srcCfg.DSN)),
|
||||
SourceDSN: maskDSN(srcCfg.DSN),
|
||||
TargetDriver: string(ResolveDriverFromConfig(cfg.Driver, cfg.DSN)),
|
||||
TargetDSN: maskDSN(cfg.DSN),
|
||||
StartTime: time.Now().UTC(),
|
||||
}
|
||||
storeMigMu.Unlock()
|
||||
|
||||
exclusiveLeaseHandedOff = true
|
||||
go runStoreMigration(ctx, cancel, done, srcCfg, cfg, dst, targetFP)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runStoreMigration 执行实际的搬运逻辑(在独立 goroutine 中)。
|
||||
func runStoreMigration(ctx context.Context, cancel context.CancelFunc, done chan struct{}, srcCfg *MetricStoreConfig, cfg *MetricStoreConfig, dst *metric.Store, targetFP string) {
|
||||
defer func() {
|
||||
cancel()
|
||||
storeOperations.Release()
|
||||
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = nil
|
||||
storeMigDone = nil
|
||||
close(done)
|
||||
storeMigMu.Unlock()
|
||||
}()
|
||||
|
||||
src, err := openSourceStore(ctx, srcCfg)
|
||||
if err != nil {
|
||||
finishStoreMigration("failed", fmt.Errorf("open source metrics store: %w", err))
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
observe := func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64) {
|
||||
storeMigMu.Lock()
|
||||
storeMigProgress.CurrentMetric = currentMetric
|
||||
storeMigProgress.MetricsDone = metricIndex
|
||||
storeMigProgress.TotalMetrics = totalMetrics
|
||||
storeMigProgress.MigratedPoints += addedPoints
|
||||
storeMigMu.Unlock()
|
||||
}
|
||||
|
||||
total, err := migrateBetweenStores(ctx, src, dst, observe)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
finishStoreMigration("canceled", nil)
|
||||
log.Printf("[store-migration] canceled after %d points", total)
|
||||
return
|
||||
}
|
||||
finishStoreMigration("failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 搬运成功:登记当前目标指纹,避免下次启动重复搬运。
|
||||
if err := config.Set(MigrationTargetKey, targetFP); err != nil {
|
||||
log.Printf("[store-migration] failed to persist migration target fingerprint: %v", err)
|
||||
}
|
||||
finishStoreMigration("completed", nil)
|
||||
log.Printf("[store-migration] completed via API (%d points) target_driver=%s", total, ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
}
|
||||
|
||||
// finishStoreMigration 统一收尾:设置终态状态、结束时间与错误信息。
|
||||
func finishStoreMigration(status string, err error) {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
storeMigProgress.Status = status
|
||||
storeMigProgress.EndTime = time.Now().UTC()
|
||||
if err != nil {
|
||||
storeMigProgress.Error = err.Error()
|
||||
}
|
||||
// 完成/取消时把当前指标标记为已全部处理,便于前端进度条归位。
|
||||
if status == "completed" && storeMigProgress.TotalMetrics > 0 {
|
||||
storeMigProgress.MetricsDone = storeMigProgress.TotalMetrics
|
||||
}
|
||||
}
|
||||
|
||||
// CancelStoreMigration 请求取消正在运行的 store-to-store 迁移,并等待其退出。
|
||||
// 由于写入是幂等 upsert,取消后可安全重新发起,不会产生重复数据。
|
||||
func CancelStoreMigration() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return stopStoreMigration(ctx, true)
|
||||
}
|
||||
|
||||
func stopStoreMigration(ctx context.Context, requireRunning bool) error {
|
||||
storeMigMu.Lock()
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
if requireRunning {
|
||||
return fmt.Errorf("no store migration is currently running")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func stopStoreMigrationForClose(ctx context.Context) error {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = true
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func clearStoreClosing() {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = false
|
||||
storeMigMu.Unlock()
|
||||
}
|
||||
|
||||
func isStoreClosing() bool {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeClosing
|
||||
}
|
||||
|
||||
// maskDSN 对 DSN 做粗粒度脱敏,避免在 API 响应/日志中泄露密码。
|
||||
// 处理两类常见格式:URL(scheme://user:pass@host/...)与 key=value(password=...)。
|
||||
func maskDSN(dsn string) string {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return ""
|
||||
}
|
||||
// key=value 形式:屏蔽 password=... 段。
|
||||
if strings.Contains(dsn, "password=") {
|
||||
parts := strings.Fields(dsn)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(strings.ToLower(p), "password=") {
|
||||
parts[i] = "password=***"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
// URL / user:pass@host 形式:屏蔽 user:pass@ 中的密码。
|
||||
if at := strings.LastIndex(dsn, "@"); at > 0 {
|
||||
head := dsn[:at]
|
||||
tail := dsn[at:]
|
||||
if colon := strings.LastIndex(head, ":"); colon >= 0 {
|
||||
// 保留 scheme://user,屏蔽密码。
|
||||
return head[:colon] + ":***" + tail
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalTime is a custom time type for GORM to handle time.Time correctly.
|
||||
// It ensures that all times are stored and retrieved based on the application's configured timezone.
|
||||
type LocalTime time.Time
|
||||
|
||||
// Value implements the driver.Valuer interface.
|
||||
// It converts UTCTime to a TEXT format that the database understands.
|
||||
// The time is formatted as a string in the application's local timezone, without timezone information.
|
||||
func (t LocalTime) Value() (driver.Value, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface.
|
||||
// It scans a value from the database into a UTCTime object.
|
||||
func (t *LocalTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
loc := GetAppLocation()
|
||||
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
|
||||
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
|
||||
// the date and clock values in the application's actual timezone.
|
||||
year, month, day := val.Date()
|
||||
hour, min, sec := val.Clock()
|
||||
nanosec := val.Nanosecond()
|
||||
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
|
||||
return nil
|
||||
case []byte:
|
||||
return t.parseTime(string(val), loc)
|
||||
case string:
|
||||
return t.parseTime(val, loc)
|
||||
default:
|
||||
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTime handles parsing a string into UTCTime.
|
||||
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
if timeStr == "" {
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
|
||||
*t = LocalTime(parsedTime)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
|
||||
func (t LocalTime) MarshalJSON() ([]byte, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
|
||||
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
|
||||
}
|
||||
|
||||
// ToTime converts UTCTime to Go's native time.Time type.
|
||||
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
|
||||
|
||||
// FromTime converts Go's native time.Time type to UTCTime.
|
||||
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
|
||||
|
||||
// Now returns the current time in the application's configured location.
|
||||
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
|
||||
|
||||
var (
|
||||
appLocation *time.Location
|
||||
locationOnce sync.Once
|
||||
)
|
||||
|
||||
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
|
||||
func GetAppLocation() *time.Location {
|
||||
locationOnce.Do(func() {
|
||||
tz := os.Getenv("TZ")
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
|
||||
appLocation = time.UTC
|
||||
} else {
|
||||
appLocation = loc
|
||||
}
|
||||
time.Local = appLocation
|
||||
log.Printf("Application timezone is set to '%s'.", appLocation.String())
|
||||
})
|
||||
return appLocation
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Log struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);"`
|
||||
Message string `json:"message" gorm:"type:text;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null;index:idx_logs_msg_type_time,priority:1"`
|
||||
Time time.Time `json:"time" gorm:"autoCreateTime;not null;index:idx_logs_time;index:idx_logs_msg_type_time,priority:2"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
|
||||
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
}
|
||||
+40
-41
@@ -4,44 +4,43 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client represents a registered client device
|
||||
type Client struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
Hidden bool `json:"hidden" gorm:"default:false"`
|
||||
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
|
||||
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
Hidden bool `json:"hidden" gorm:"default:false"`
|
||||
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
|
||||
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
@@ -53,8 +52,8 @@ type User struct {
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
@@ -64,17 +63,17 @@ type Session struct {
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
|
||||
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
|
||||
Expires time.Time `json:"expires" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Expires LocalTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
}
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time time.Time `json:"time" gorm:"index"`
|
||||
Time LocalTime `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
@@ -100,7 +99,7 @@ type Record struct {
|
||||
// GPURecord logs individual GPU metrics over time
|
||||
type GPURecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time time.Time `json:"time" gorm:"index"` // 记录时间
|
||||
Time LocalTime `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Notification 定义了通知相关的数据库模型
|
||||
type OfflineNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
@@ -21,7 +19,7 @@ type LoadNotification struct {
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// TrafficReportNotification 定义了流量定时报告的数据库模型
|
||||
@@ -29,7 +27,7 @@ type TrafficReportNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
|
||||
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
|
||||
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
|
||||
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
|
||||
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PingRecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time time.Time `json:"time" gorm:"index;not null"`
|
||||
Time LocalTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Task struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
@@ -15,6 +13,6 @@ type TaskResult struct {
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
|
||||
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Log{}); err != nil {
|
||||
t.Fatalf("migrate log: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
|
||||
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatalf("create log: %v", err)
|
||||
}
|
||||
var loaded Log
|
||||
if err := db.First(&loaded, entry.ID).Error; err != nil {
|
||||
t.Fatalf("load log: %v", err)
|
||||
}
|
||||
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
|
||||
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(loaded)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Client{}); err != nil {
|
||||
t.Fatalf("migrate client: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
|
||||
t.Fatalf("insert client: %v", err)
|
||||
}
|
||||
|
||||
var client Client
|
||||
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
|
||||
t.Fatalf("load client: %v", err)
|
||||
}
|
||||
if client.ExpiredAt != nil {
|
||||
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
|
||||
}
|
||||
encoded, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal client: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"expired_at":null`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,6 @@ func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func DeleteOidcConfigByName(name string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
|
||||
+592
-17
@@ -1,34 +1,609 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
|
||||
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
|
||||
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
|
||||
//
|
||||
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
|
||||
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
|
||||
|
||||
func DeleteAll() error {
|
||||
return metricstore.DeleteAllRecords(context.Background())
|
||||
func RecordOne(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
|
||||
func RecordGPU(rec models.GPURecord) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func DeleteAll() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("DELETE FROM records").Error
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取GPU记录数据
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.GPURecord
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recentRecords []models.GPURecord
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
|
||||
Order("time ASC, device_index ASC").Find(&recentRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var longTermRecords []models.GPURecord
|
||||
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
|
||||
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
|
||||
return recentRecords, nil
|
||||
}
|
||||
|
||||
// 合并结果 - 不再需要类型转换
|
||||
records = append(records, recentRecords...)
|
||||
records = append(records, longTermRecords...)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteRecordBefore(before time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
|
||||
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
db.Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
return db.Where("time < ?", before).Delete(&models.Record{}).Error
|
||||
}
|
||||
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.Record
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recentRecords []models.Record
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var long_term []models.Record
|
||||
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
|
||||
return recentRecords, nil
|
||||
}
|
||||
|
||||
if len(long_term) == 0 {
|
||||
// 没有查到long_term,返回全部recentRecords
|
||||
records = append(records, recentRecords...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
|
||||
grouped := make(map[string]models.Record)
|
||||
for _, rec := range recentRecords {
|
||||
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
|
||||
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
|
||||
grouped[key] = rec
|
||||
}
|
||||
}
|
||||
var groupedList []models.Record
|
||||
for _, rec := range grouped {
|
||||
groupedList = append(groupedList, rec)
|
||||
}
|
||||
sort.Slice(groupedList, func(i, j int) bool {
|
||||
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
|
||||
})
|
||||
records = append(records, groupedList...)
|
||||
records = append(records, long_term...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
|
||||
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end)
|
||||
func GetAllRecords() ([]models.Record, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.Record
|
||||
var long_term []models.Record
|
||||
err := db.Table("records").Order("time ASC").Find(&records).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching all records: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term records: %v", err)
|
||||
return records, nil
|
||||
}
|
||||
records = append(records, long_term...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// 压缩数据库
|
||||
func CompactRecord() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := migrateOldRecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating old records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = migrateGPURecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating GPU records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.IsSQLite() {
|
||||
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
|
||||
}
|
||||
//log.Printf("Record compaction completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOldRecords(db *gorm.DB) error {
|
||||
return migrateOldRecordsAt(db, time.Now())
|
||||
}
|
||||
|
||||
func compactRecordCutoff(now time.Time) time.Time {
|
||||
return now.Add(-4 * time.Hour).Truncate(15 * time.Minute)
|
||||
}
|
||||
|
||||
func migrateOldRecordsAt(db *gorm.DB, now time.Time) error {
|
||||
cutoff := compactRecordCutoff(now)
|
||||
|
||||
// 查询 records 表中超过 4 小时的记录
|
||||
var records []models.Record
|
||||
if err := db.Table("records").Where("time < ?", cutoff).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
previousByClient, err := getPreviousTrafficRecordsBefore(db, records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repairZeroTrafficDeltas(records, previousByClient)
|
||||
|
||||
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
|
||||
type groupData struct {
|
||||
Cpu []float32
|
||||
Gpu []float32
|
||||
Load []float32
|
||||
Temp []float32
|
||||
Ram []int64
|
||||
RamTotal []int64
|
||||
Swap []int64
|
||||
SwapTotal []int64
|
||||
Disk []int64
|
||||
DiskTotal []int64
|
||||
NetIn []int64
|
||||
NetOut []int64
|
||||
NetTotalUp []int64
|
||||
NetTotalDown []int64
|
||||
TrafficUp int64
|
||||
TrafficDown int64
|
||||
LatestTime time.Time
|
||||
LatestTotalUp int64
|
||||
LatestTotalDown int64
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
}
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
for _, record := range records {
|
||||
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
|
||||
if _, ok := groupedRecords[key]; !ok {
|
||||
groupedRecords[key] = &groupData{}
|
||||
}
|
||||
data := groupedRecords[key]
|
||||
data.Cpu = append(data.Cpu, record.Cpu)
|
||||
data.Gpu = append(data.Gpu, record.Gpu)
|
||||
data.Load = append(data.Load, record.Load)
|
||||
data.Temp = append(data.Temp, record.Temp)
|
||||
data.Ram = append(data.Ram, record.Ram)
|
||||
data.RamTotal = append(data.RamTotal, record.RamTotal)
|
||||
data.Swap = append(data.Swap, record.Swap)
|
||||
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
|
||||
data.Disk = append(data.Disk, record.Disk)
|
||||
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
|
||||
data.NetIn = append(data.NetIn, record.NetIn)
|
||||
data.NetOut = append(data.NetOut, record.NetOut)
|
||||
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
|
||||
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
|
||||
data.TrafficUp += record.TrafficUp
|
||||
data.TrafficDown += record.TrafficDown
|
||||
if data.LatestTime.IsZero() || record.Time.ToTime().After(data.LatestTime) {
|
||||
data.LatestTime = record.Time.ToTime()
|
||||
data.LatestTotalUp = record.NetTotalUp
|
||||
data.LatestTotalDown = record.NetTotalDown
|
||||
}
|
||||
data.Process = append(data.Process, record.Process)
|
||||
data.Connections = append(data.Connections, record.Connections)
|
||||
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
|
||||
//data.Uptime = append(data.Uptime, record.Uptime)
|
||||
}
|
||||
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sortedValues := make([]float64, len(values))
|
||||
copy(sortedValues, values)
|
||||
sort.Float64s(sortedValues)
|
||||
index := float64(len(sortedValues)-1) * percentile
|
||||
lowerIndex := int(index)
|
||||
if lowerIndex >= len(sortedValues)-1 {
|
||||
return sortedValues[len(sortedValues)-1]
|
||||
}
|
||||
frac := index - float64(lowerIndex)
|
||||
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
|
||||
}
|
||||
|
||||
getIntPercentile := func(values []int64, percentile float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int64(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
getInt32Percentile := func(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, data := range groupedRecords {
|
||||
// 解析 Client 和时间
|
||||
parts := strings.Split(key, "_")
|
||||
clientUUID := parts[0]
|
||||
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cpuFloats := make([]float64, len(data.Cpu))
|
||||
for i, v := range data.Cpu {
|
||||
cpuFloats[i] = float64(v)
|
||||
}
|
||||
gpuFloats := make([]float64, len(data.Gpu))
|
||||
for i, v := range data.Gpu {
|
||||
gpuFloats[i] = float64(v)
|
||||
}
|
||||
loadFloats := make([]float64, len(data.Load))
|
||||
for i, v := range data.Load {
|
||||
loadFloats[i] = float64(v)
|
||||
}
|
||||
tempFloats := make([]float64, len(data.Temp))
|
||||
for i, v := range data.Temp {
|
||||
tempFloats[i] = float64(v)
|
||||
}
|
||||
// 取高位
|
||||
high_percentile := 0.7
|
||||
// 检查 records_long_term 表中是否已存在相同的记录
|
||||
var existingCount int64
|
||||
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newRec := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(timeSlot),
|
||||
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
|
||||
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
|
||||
Load: float32(getPercentile(loadFloats, high_percentile)),
|
||||
Temp: float32(getPercentile(tempFloats, high_percentile)),
|
||||
Ram: getIntPercentile(data.Ram, high_percentile),
|
||||
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
|
||||
Swap: getIntPercentile(data.Swap, high_percentile),
|
||||
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
|
||||
Disk: getIntPercentile(data.Disk, high_percentile),
|
||||
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
|
||||
NetIn: getIntPercentile(data.NetIn, 0.2),
|
||||
NetOut: getIntPercentile(data.NetOut, 0.2),
|
||||
NetTotalUp: data.LatestTotalUp,
|
||||
NetTotalDown: data.LatestTotalDown,
|
||||
TrafficUp: data.TrafficUp,
|
||||
TrafficDown: data.TrafficDown,
|
||||
Process: getInt32Percentile(data.Process, high_percentile),
|
||||
Connections: getInt32Percentile(data.Connections, high_percentile),
|
||||
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
|
||||
//Uptime: getIntPercentile(data.Uptime, high_percentile),
|
||||
}
|
||||
|
||||
// 如果记录已存在则更新,否则创建新记录
|
||||
if existingCount > 0 {
|
||||
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 records 表中的旧数据
|
||||
if err := tx.Table("records").Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func getPreviousTrafficRecordsBefore(db *gorm.DB, records []models.Record) (map[string]*models.Record, error) {
|
||||
firstTimeByClient := make(map[string]time.Time)
|
||||
for _, record := range records {
|
||||
recordTime := record.Time.ToTime()
|
||||
firstTime, ok := firstTimeByClient[record.Client]
|
||||
if !ok || recordTime.Before(firstTime) {
|
||||
firstTimeByClient[record.Client] = recordTime
|
||||
}
|
||||
}
|
||||
|
||||
previousByClient := make(map[string]*models.Record, len(firstTimeByClient))
|
||||
for clientUUID, firstTime := range firstTimeByClient {
|
||||
previous, err := getPreviousTrafficRecordBefore(db, clientUUID, firstTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if previous != nil {
|
||||
previousByClient[clientUUID] = previous
|
||||
}
|
||||
}
|
||||
return previousByClient, nil
|
||||
}
|
||||
|
||||
func getPreviousTrafficRecordBefore(db *gorm.DB, clientUUID string, before time.Time) (*models.Record, error) {
|
||||
var latest *models.Record
|
||||
for _, table := range []string{"records", "records_long_term"} {
|
||||
var record models.Record
|
||||
queryBefore := before
|
||||
if table == "records_long_term" {
|
||||
queryBefore = before.Truncate(15 * time.Minute)
|
||||
}
|
||||
err := db.Table(table).
|
||||
Where("client = ? AND time < ?", clientUUID, models.FromTime(queryBefore)).
|
||||
Order("time DESC").
|
||||
First(&record).Error
|
||||
if err == nil {
|
||||
if latest == nil || record.Time.ToTime().After(latest.Time.ToTime()) {
|
||||
latest = &record
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func repairZeroTrafficDeltas(records []models.Record, previousByClient map[string]*models.Record) {
|
||||
recordsByClient := make(map[string][]*models.Record)
|
||||
for i := range records {
|
||||
recordsByClient[records[i].Client] = append(recordsByClient[records[i].Client], &records[i])
|
||||
}
|
||||
|
||||
for _, clientRecords := range recordsByClient {
|
||||
sort.Slice(clientRecords, func(i, j int) bool {
|
||||
return clientRecords[i].Time.ToTime().Before(clientRecords[j].Time.ToTime())
|
||||
})
|
||||
var previous *models.Record
|
||||
if previousByClient != nil {
|
||||
previous = previousByClient[clientRecords[0].Client]
|
||||
}
|
||||
for _, current := range clientRecords {
|
||||
if previous == nil {
|
||||
previous = current
|
||||
continue
|
||||
}
|
||||
if current.TrafficUp == 0 {
|
||||
current.TrafficUp = utils.ComputeTrafficDelta(current.NetTotalUp, previous.NetTotalUp)
|
||||
}
|
||||
if current.TrafficDown == 0 {
|
||||
current.TrafficDown = utils.ComputeTrafficDelta(current.NetTotalDown, previous.NetTotalDown)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateGPURecords 压缩GPU记录数据
|
||||
func migrateGPURecords(db *gorm.DB) error {
|
||||
cutoff := compactRecordCutoff(time.Now())
|
||||
|
||||
// 查询超过4小时的GPU记录
|
||||
var gpuRecords []models.GPURecord
|
||||
if err := db.Where("time < ?", cutoff).Find(&gpuRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(gpuRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按Client + DeviceIndex + 15分钟时间窗口分组
|
||||
type gpuGroupKey struct {
|
||||
Client string
|
||||
DeviceIndex int
|
||||
TimeSlot time.Time
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type gpuGroupData struct {
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float32
|
||||
Temperature []int
|
||||
}
|
||||
|
||||
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
key := gpuGroupKey{
|
||||
Client: record.Client,
|
||||
DeviceIndex: record.DeviceIndex,
|
||||
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
|
||||
DeviceName: record.DeviceName,
|
||||
}
|
||||
|
||||
if _, ok := groupedGPUs[key]; !ok {
|
||||
groupedGPUs[key] = &gpuGroupData{}
|
||||
}
|
||||
|
||||
data := groupedGPUs[key]
|
||||
data.MemTotal = append(data.MemTotal, record.MemTotal)
|
||||
data.MemUsed = append(data.MemUsed, record.MemUsed)
|
||||
data.Utilization = append(data.Utilization, record.Utilization)
|
||||
data.Temperature = append(data.Temperature, record.Temperature)
|
||||
}
|
||||
|
||||
// 百分位数计算函数 (复用传统Record压缩逻辑)
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sortedValues := make([]float64, len(values))
|
||||
copy(sortedValues, values)
|
||||
sort.Float64s(sortedValues)
|
||||
index := float64(len(sortedValues)-1) * percentile
|
||||
lowerIndex := int(index)
|
||||
if lowerIndex >= len(sortedValues)-1 {
|
||||
return sortedValues[len(sortedValues)-1]
|
||||
}
|
||||
frac := index - float64(lowerIndex)
|
||||
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
|
||||
}
|
||||
|
||||
getIntPercentile := func(values []int64, percentile float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int64(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 温度数据转换辅助函数
|
||||
convertIntToInt64 := func(values []int) []int64 {
|
||||
result := make([]int64, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = int64(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getFloat32Percentile := func(values []float32, percentile float64) float32 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return float32(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 保持与传统Record压缩的一致性
|
||||
high_percentile := 0.7
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, data := range groupedGPUs {
|
||||
// 检查是否已存在记录
|
||||
var existingCount int64
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compressedGPU := models.GPURecord{
|
||||
Client: key.Client,
|
||||
DeviceIndex: key.DeviceIndex,
|
||||
Time: models.FromTime(key.TimeSlot),
|
||||
DeviceName: key.DeviceName,
|
||||
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
|
||||
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
|
||||
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
|
||||
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
|
||||
}
|
||||
|
||||
if existingCount > 0 {
|
||||
// 更新已存在记录
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 创建新记录
|
||||
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除已压缩的原始GPU数据
|
||||
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
|
||||
|
||||
var _ = func() bool {
|
||||
// 确保 Test 环境中使用 sqlite 内存数据库
|
||||
return true
|
||||
}()
|
||||
|
||||
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
|
||||
// then running migrateOldRecords and verifying the aggregation and cleanup.
|
||||
func TestCompactRecord(t *testing.T) {
|
||||
const totalMinutes = 12*60 + 30
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
|
||||
threshold := compactRecordCutoff(now)
|
||||
overlapCutoff := threshold.Add(-1 * time.Hour)
|
||||
|
||||
// 使用 sqlite 内存数据库并迁移表结构
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
expectedGroups := make(map[time.Time]struct{})
|
||||
expectedRawRemain := 0
|
||||
|
||||
// 插入数据
|
||||
for i := 0; i < totalMinutes; i++ {
|
||||
recTime := now.Add(-time.Duration(i) * time.Minute)
|
||||
rec := models.Record{Client: uuid, Time: models.FromTime(recTime), Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
|
||||
err := db.Create(&rec).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
if recTime.Before(threshold) {
|
||||
slot := recTime.Truncate(15 * time.Minute)
|
||||
expectedGroups[slot] = struct{}{}
|
||||
}
|
||||
if !recTime.Before(overlapCutoff) {
|
||||
expectedRawRemain++
|
||||
}
|
||||
}
|
||||
|
||||
// 导出原始数据到 CSV
|
||||
os.MkdirAll("../../data", 0755)
|
||||
var origRecs []models.Record
|
||||
db.Order("time desc").Find(&origRecs)
|
||||
fOrig, err := os.Create("../../data/original.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fOrig.Close()
|
||||
wOrig := csv.NewWriter(fOrig)
|
||||
defer wOrig.Flush()
|
||||
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range origRecs {
|
||||
wOrig.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
|
||||
// 运行压缩(迁移)逻辑
|
||||
err = migrateOldRecordsAt(db, now)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证 long-term 表中的聚合记录数
|
||||
var longCount int64
|
||||
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
|
||||
assert.Equal(t, int64(len(expectedGroups)), longCount)
|
||||
|
||||
// 验证原始表中剩余记录数
|
||||
var remainCount int64
|
||||
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
|
||||
assert.Equal(t, int64(expectedRawRemain), remainCount)
|
||||
|
||||
// 导出压缩后的数据到 CSV
|
||||
var compRecs []models.Record
|
||||
db.Table("records_long_term").Order("time desc").Find(&compRecs)
|
||||
fComp, err := os.Create("../../data/compressed.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fComp.Close()
|
||||
wComp := csv.NewWriter(fComp)
|
||||
defer wComp.Flush()
|
||||
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range compRecs {
|
||||
wComp.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
|
||||
db.Table("records").Order("time desc").Find(&compRecs)
|
||||
fComp, err = os.Create("../../data/compressed_records.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fComp.Close()
|
||||
wComp = csv.NewWriter(fComp)
|
||||
defer wComp.Flush()
|
||||
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range compRecs {
|
||||
wComp.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
currentTime := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
|
||||
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
records := []models.Record{
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now),
|
||||
NetTotalUp: 100,
|
||||
NetTotalDown: 200,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(5 * time.Minute)),
|
||||
NetTotalUp: 150,
|
||||
NetTotalDown: 260,
|
||||
TrafficUp: 50,
|
||||
TrafficDown: 60,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(10 * time.Minute)),
|
||||
NetTotalUp: 10,
|
||||
NetTotalDown: 30,
|
||||
TrafficUp: 10,
|
||||
TrafficDown: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
|
||||
require.Len(t, compacted, 1)
|
||||
assert.Equal(t, int64(60), compacted[0].TrafficUp)
|
||||
assert.Equal(t, int64(90), compacted[0].TrafficDown)
|
||||
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
|
||||
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasPreservesRawResetDetailBeforeCompaction(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
start := time.Date(2026, 6, 6, 0, 0, 0, 0, loc)
|
||||
records := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(start), NetTotalUp: 100, NetTotalDown: 200},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 140, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 20},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 35},
|
||||
}
|
||||
|
||||
repairZeroTrafficDeltas(records, nil)
|
||||
|
||||
assert.Equal(t, int64(0), records[0].TrafficUp)
|
||||
assert.Equal(t, int64(0), records[0].TrafficDown)
|
||||
assert.Equal(t, int64(40), records[1].TrafficUp)
|
||||
assert.Equal(t, int64(60), records[1].TrafficDown)
|
||||
assert.Equal(t, int64(10), records[2].TrafficUp)
|
||||
assert.Equal(t, int64(20), records[2].TrafficDown)
|
||||
assert.Equal(t, int64(15), records[3].TrafficUp)
|
||||
assert.Equal(t, int64(15), records[3].TrafficDown)
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasUsesPreviousPersistedBaseline(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 0, 0, 0, loc)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(-10 * time.Minute)),
|
||||
NetTotalUp: 100,
|
||||
NetTotalDown: 200,
|
||||
}).Error)
|
||||
|
||||
rawRecords := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
|
||||
}
|
||||
|
||||
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
|
||||
assert.NoError(t, err)
|
||||
repairZeroTrafficDeltas(rawRecords, previousByClient)
|
||||
|
||||
assert.Equal(t, int64(50), rawRecords[0].TrafficUp)
|
||||
assert.Equal(t, int64(60), rawRecords[0].TrafficDown)
|
||||
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
|
||||
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasIgnoresSameSlotLongTermBaseline(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 0, 30, 0, loc)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Truncate(15 * time.Minute)),
|
||||
NetTotalUp: 900,
|
||||
NetTotalDown: 900,
|
||||
}).Error)
|
||||
|
||||
rawRecords := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
|
||||
}
|
||||
|
||||
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
|
||||
assert.NoError(t, err)
|
||||
repairZeroTrafficDeltas(rawRecords, previousByClient)
|
||||
|
||||
assert.Equal(t, int64(0), rawRecords[0].TrafficUp)
|
||||
assert.Equal(t, int64(0), rawRecords[0].TrafficDown)
|
||||
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
|
||||
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
|
||||
}
|
||||
|
||||
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
records := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
|
||||
}
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var remainTimes []models.Record
|
||||
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
|
||||
require.Len(t, remainTimes, 2)
|
||||
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
|
||||
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
|
||||
}
|
||||
|
||||
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
|
||||
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
|
||||
assert.NoError(t, db.Create(&compactable).Error)
|
||||
assert.NoError(t, db.Create(&partialSlot).Error)
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
|
||||
require.Len(t, compacted, 1)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
|
||||
|
||||
var rawCount int64
|
||||
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
|
||||
assert.Equal(t, int64(1), rawCount)
|
||||
}
|
||||
+33
-16
@@ -1,11 +1,9 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
@@ -47,12 +45,6 @@ func AddPingTask(clients []string, defaultOn bool, name string, target, task_typ
|
||||
}
|
||||
|
||||
func DeletePingTask(id []uint) error {
|
||||
// The metric store is independent from the main database, so clean it first
|
||||
// to avoid leaving history that can no longer be addressed through the task.
|
||||
if err := DeletePingRecords(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
|
||||
if result.RowsAffected == 0 {
|
||||
@@ -133,21 +125,34 @@ func UpdatePingTaskOrder(order map[uint]int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
|
||||
// metric store,旧 ping_records 表不再参与。
|
||||
|
||||
func SavePingRecord(record models.PingRecord) error {
|
||||
return metricstore.WritePingRecord(context.Background(), record)
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
}
|
||||
|
||||
func DeletePingRecordsBefore(time time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeletePingRecords(id []uint) error {
|
||||
return metricstore.DeletePingRecordsByTask(context.Background(), id)
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func DeleteAllPingRecords() error {
|
||||
return metricstore.DeleteAllPingRecords(context.Background())
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Exec("DELETE FROM ping_records")
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func ReloadPingSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var pingTasks []models.PingTask
|
||||
@@ -196,5 +201,17 @@ func AddDefaultOnClientUUID(uuid string) error {
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.PingRecord
|
||||
dbQuery := db.Model(&models.PingRecord{})
|
||||
if uuid != "" {
|
||||
dbQuery = dbQuery.Where("client = ?", uuid)
|
||||
}
|
||||
if taskId >= 0 {
|
||||
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
|
||||
}
|
||||
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
+15
-4
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
|
||||
Result: "",
|
||||
ExitCode: nil,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
})
|
||||
}
|
||||
if len(taskResults) > 0 {
|
||||
@@ -57,6 +57,13 @@ func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func GetAllTasksResultByUUID(uuid string) ([]models.TaskResult, error) {
|
||||
var results []models.TaskResult
|
||||
if err := dbcore.GetDBInstance().Where("client = ?", uuid).Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func GetAllTasks() ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
|
||||
@@ -72,17 +79,21 @@ func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
func DeleteTaskByTaskId(taskId string) error {
|
||||
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
|
||||
}
|
||||
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
Where("task_id = ? AND client = ?", taskId, clientId).
|
||||
Updates(map[string]interface{}{
|
||||
"result": result,
|
||||
"exit_code": exitCode,
|
||||
"finished_at": timestamp.UTC(),
|
||||
"finished_at": timestamp,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func ClearTaskResultsByTimeBefore(before time.Time) error {
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.UTC()).Delete(&models.TaskResult{}).Error
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.Format(time.RFC3339)).Delete(&models.TaskResult{}).Error
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
taskID := "task-cleanup-time"
|
||||
if err := db.Create(&models.Task{TaskId: taskID, Command: "true"}).Error; err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
cutoff := time.Date(2026, 7, 17, 12, 0, 0, 123456789, time.UTC)
|
||||
results := []models.TaskResult{
|
||||
{TaskId: taskID, Client: "old", CreatedAt: cutoff.Add(-time.Nanosecond)},
|
||||
{TaskId: taskID, Client: "boundary", CreatedAt: cutoff},
|
||||
{TaskId: taskID, Client: "new", CreatedAt: cutoff.Add(time.Nanosecond)},
|
||||
}
|
||||
if err := db.Create(&results).Error; err != nil {
|
||||
t.Fatalf("create task results: %v", err)
|
||||
}
|
||||
|
||||
localCutoff := cutoff.In(time.FixedZone("UTC+8", 8*60*60))
|
||||
if err := ClearTaskResultsByTimeBefore(localCutoff); err != nil {
|
||||
t.Fatalf("clear task results: %v", err)
|
||||
}
|
||||
var remaining []models.TaskResult
|
||||
if err := db.Where("task_id = ?", taskID).Order("created_at").Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("load remaining results: %v", err)
|
||||
}
|
||||
if len(remaining) != 2 || remaining[0].Client != "boundary" || remaining[1].Client != "new" {
|
||||
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -1,7 +1,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
)
|
||||
@@ -44,6 +42,15 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
if !hasKey("o_auth_provider") {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if !hasKey("record_enabled") {
|
||||
cst.RecordEnabled = true
|
||||
}
|
||||
if !hasKey("record_preserve_time") {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if !hasKey("ping_record_preserve_time") {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
|
||||
// Fallback defaults if we couldn't enumerate keys.
|
||||
if allErr != nil {
|
||||
@@ -54,10 +61,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
}
|
||||
retention, err := metricstore.GetRetentionSummary(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
@@ -131,11 +135,10 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
"oauth_provider": cst.OAuthProvider,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
|
||||
"record_enabled": retention.AllPositive, // 兼容旧版本主题
|
||||
"record_preserve_time": retention.MaxDays * 24,
|
||||
"ping_record_preserve_time": retention.MaxDays * 24,
|
||||
"record_enabled": cst.RecordEnabled,
|
||||
"record_preserve_time": cst.RecordPreserveTime,
|
||||
"ping_record_preserve_time": cst.PingRecordPreserveTime,
|
||||
"private_site": cst.PrivateSite,
|
||||
"visitor_audit_enabled": cst.VisitorAuditEnabled,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
}, nil
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
# Visitor Audit RPC 使用说明
|
||||
|
||||
本文档说明 `public:recordVisitorEvent` RPC2 接口的调用方式。该接口用于让主题前端把访客访问和前端操作记录写入 Komari 后端审计日志。
|
||||
|
||||
## 接口概览
|
||||
|
||||
- RPC 方法:`public:recordVisitorEvent`
|
||||
- RPC 路径:`/api/rpc2`
|
||||
- HTTP 方法:`POST`
|
||||
- 写入位置:现有审计日志表 `models.Log`
|
||||
- 管理端读取:复用现有 `admin:getLogs`
|
||||
- 日志类型:`msg_type = "visitor"`
|
||||
- 默认状态:关闭,需设置 `visitor_audit_enabled = true`
|
||||
|
||||
该接口不会信任前端传入的 IP。来源 IP 和 User-Agent 由后端从请求上下文记录。
|
||||
|
||||
## 权限
|
||||
|
||||
`public:recordVisitorEvent` 属于 `public` 命名空间,访客可调用。
|
||||
|
||||
私有站点模式下,该方法也在登录页白名单内,便于记录登录前/前台访问事件。
|
||||
|
||||
这是有意保留的行为;总开关关闭时,白名单中的方法仍可调用,但不会写库。
|
||||
|
||||
## 启用
|
||||
|
||||
存量实例和新实例默认都不会开放匿名日志写入。管理员可通过
|
||||
`admin:editSettings` 将 `visitor_audit_enabled` 设为 `true`。公开设置
|
||||
`public:getPublicSettings` 也会返回这个字段,主题可据此决定是否上报。
|
||||
|
||||
未启用时接口返回 `status = "disabled"`。启用后,每个来源 IP 使用内存令牌桶限流:
|
||||
平均每分钟 30 次,允许短时突发 10 次;超限时返回 `status = "rate_limited"`,不写库。
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `event` | `string` | 是 | 事件名,例如 `page_view`、`node_open`、`search` |
|
||||
| `action` | `string` | 否 | `event` 的别名 |
|
||||
| `operation` | `string` | 否 | `event` 的别名 |
|
||||
| `path` | `string` | 否 | 前端路径,例如 `/`、`/instance/<uuid>` |
|
||||
| `route` | `string` | 否 | 前端路由名,例如 `home`、`instance-detail` |
|
||||
| `target` | `string` | 否 | 操作目标,例如节点 UUID、工具 key、按钮 key |
|
||||
| `detail` | `object` | 否 | 额外元数据,大小受限 |
|
||||
|
||||
`event`、`action`、`operation` 三者任选一个即可,优先级为:
|
||||
|
||||
```text
|
||||
event > action > operation
|
||||
```
|
||||
|
||||
## 后端自动记录的信息
|
||||
|
||||
| 字段 | 来源 |
|
||||
| --- | --- |
|
||||
| IP | `rpc.ContextMeta.RemoteIP`,来自服务端 `c.ClientIP()` |
|
||||
| User-Agent | `rpc.ContextMeta.UserAgent`,来自请求头 |
|
||||
| 用户 UUID | 登录状态下使用 `rpc.ContextMeta.UserUUID`;访客为空 |
|
||||
| 时间 | 后端写入审计日志时生成 |
|
||||
|
||||
## 字段限制
|
||||
|
||||
字符串字段按 Unicode 字符计数,`detail` 和最终 message 按序列化后的字节数计数。
|
||||
|
||||
| 字段 | 最大长度 |
|
||||
| --- | ---: |
|
||||
| `event` | 64 |
|
||||
| `path` | 512 |
|
||||
| `route` | 128 |
|
||||
| `target` | 128 |
|
||||
| User-Agent | 512 |
|
||||
| `detail` JSON | 2048 |
|
||||
| 最终日志 message | 4096 |
|
||||
|
||||
如果 `detail` 超过限制,不会完整保存,而会写入截断标记:
|
||||
|
||||
```json
|
||||
{
|
||||
"truncated": true,
|
||||
"size": 4096
|
||||
}
|
||||
```
|
||||
|
||||
## 事件名规范化
|
||||
|
||||
服务端会规范化事件名:
|
||||
|
||||
- 转小写
|
||||
- 空格转 `_`
|
||||
- 仅保留字母、数字、`_`、`-`、`:`、`.`
|
||||
- 最大长度 64
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
Page View -> page_view
|
||||
node:open.detail -> node:open.detail
|
||||
```
|
||||
|
||||
## 请求示例:页面访问
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:25774/api/rpc2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "public:recordVisitorEvent",
|
||||
"params": {
|
||||
"event": "page_view",
|
||||
"path": "/",
|
||||
"route": "home",
|
||||
"detail": {
|
||||
"theme": "glassmorphism"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
写入成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"status": "success"
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
`disabled` 和 `rate_limited` 也属于正常响应,不会返回 JSON-RPC 错误;主题上报不应影响主流程。
|
||||
|
||||
## 请求示例:打开节点详情
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "public:recordVisitorEvent",
|
||||
"params": {
|
||||
"event": "node_open",
|
||||
"path": "/instance/6f0b-example",
|
||||
"route": "instance-detail",
|
||||
"target": "6f0b-example",
|
||||
"detail": {
|
||||
"source": "node_card"
|
||||
}
|
||||
},
|
||||
"id": 2
|
||||
}
|
||||
```
|
||||
|
||||
## 请求示例:搜索
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "public:recordVisitorEvent",
|
||||
"params": {
|
||||
"event": "search",
|
||||
"path": "/",
|
||||
"route": "home",
|
||||
"detail": {
|
||||
"keyword_length": 6,
|
||||
"result_count": 3
|
||||
}
|
||||
},
|
||||
"id": 3
|
||||
}
|
||||
```
|
||||
|
||||
建议不要记录完整搜索关键词,避免把敏感内容写入审计日志。
|
||||
|
||||
## 前端封装示例
|
||||
|
||||
```ts
|
||||
interface VisitorAuditEvent {
|
||||
event?: string
|
||||
action?: string
|
||||
operation?: string
|
||||
path?: string
|
||||
route?: string
|
||||
target?: string
|
||||
detail?: Record<string, unknown>
|
||||
}
|
||||
|
||||
let auditRequestId = 0
|
||||
|
||||
export async function recordVisitorEvent(event: VisitorAuditEvent): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/rpc2', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'public:recordVisitorEvent',
|
||||
params: event,
|
||||
id: ++auditRequestId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// 审计上报不应影响用户正常浏览
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Vue Router 访问记录示例
|
||||
|
||||
```ts
|
||||
router.afterEach((to) => {
|
||||
void recordVisitorEvent({
|
||||
event: 'page_view',
|
||||
path: to.fullPath,
|
||||
route: String(to.name ?? ''),
|
||||
detail: {
|
||||
params: Object.keys(to.params),
|
||||
query_keys: Object.keys(to.query),
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
建议只记录 query key,不记录完整 query value,避免泄露 token 或敏感参数。
|
||||
|
||||
## 推荐事件名
|
||||
|
||||
| 场景 | event |
|
||||
| --- | --- |
|
||||
| 页面访问 | `page_view` |
|
||||
| 打开节点详情 | `node_open` |
|
||||
| 搜索 | `search` |
|
||||
| 切换分组 | `group_change` |
|
||||
| 切换视图模式 | `view_mode_change` |
|
||||
| 点击管理入口 | `admin_entry_click` |
|
||||
| 打开高级工具 | `home_tool_open` |
|
||||
| 审计日志刷新 | `audit_refresh` |
|
||||
| 审计日志翻页 | `audit_page_change` |
|
||||
| 导出 JSON | `export_json` |
|
||||
| 导出 CSV | `export_csv` |
|
||||
| WebRTC 自检开始 | `webrtc_check_start` |
|
||||
| WebRTC 自检完成 | `webrtc_check_done` |
|
||||
|
||||
## 审计日志保存形态
|
||||
|
||||
写入现有 `models.Log`:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `ip` | 服务端看到的来源 IP |
|
||||
| `uuid` | 登录用户 UUID;访客为空 |
|
||||
| `msg_type` | `visitor` |
|
||||
| `message` | `visitor event: {...}` |
|
||||
| `time` | 后端写入时间 |
|
||||
|
||||
示例 `message`:
|
||||
|
||||
```text
|
||||
visitor event: {"event":"page_view","path":"/","route":"home","user_agent":"Mozilla/5.0 ...","detail":{"theme":"glassmorphism"}}
|
||||
```
|
||||
|
||||
## 管理端读取
|
||||
|
||||
复用现有接口:
|
||||
|
||||
```text
|
||||
admin:getLogs
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "admin:getLogs",
|
||||
"params": {
|
||||
"limit": "100",
|
||||
"page": "1",
|
||||
"msg_type": "visitor"
|
||||
},
|
||||
"id": 10
|
||||
}
|
||||
```
|
||||
|
||||
`msg_type` 为可选的精确匹配过滤参数。传入 `visitor` 后,计数和分页都会在 SQL 查询层过滤,
|
||||
无需先拉取所有日志再由前端筛选。
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
### 不要从前端传 IP
|
||||
|
||||
前端 IP 不可信。该接口会自动使用服务端看到的来源 IP。
|
||||
|
||||
### 不要记录敏感值
|
||||
|
||||
不建议记录:
|
||||
|
||||
- 密码
|
||||
- token
|
||||
- cookie
|
||||
- 完整 URL query
|
||||
- 完整搜索关键词
|
||||
- 导出内容
|
||||
- WebSSH 命令内容
|
||||
- 剪贴板内容
|
||||
|
||||
建议只记录:
|
||||
|
||||
- 操作类型
|
||||
- 路由
|
||||
- 目标 ID
|
||||
- 结果数量
|
||||
- 布尔状态
|
||||
- 非敏感摘要
|
||||
|
||||
### 上报不要阻塞主流程
|
||||
|
||||
前端调用建议使用:
|
||||
|
||||
```ts
|
||||
void recordVisitorEvent(...)
|
||||
```
|
||||
|
||||
即使上报失败,也不影响页面功能。
|
||||
@@ -3,6 +3,7 @@ module github.com/komari-monitor/komari
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
@@ -17,6 +18,8 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/image v0.35.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
)
|
||||
@@ -39,12 +42,12 @@ require (
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -66,9 +69,10 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -12,6 +12,8 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -29,6 +31,12 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -43,6 +51,8 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -125,6 +135,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
@@ -133,6 +155,8 @@ golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -140,6 +164,12 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -226,9 +226,6 @@ detect_arch() {
|
||||
riscv64)
|
||||
echo "riscv64"
|
||||
;;
|
||||
loongarch64|loong64)
|
||||
echo "loong64"
|
||||
;;
|
||||
*)
|
||||
log_error "不支持的架构: $arch"
|
||||
exit 1
|
||||
|
||||
+92
-1
@@ -31,6 +31,26 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// Get 获取原始值 (反序列化为 interface{})
|
||||
func Get(key string, defaul ...any) (any, error) {
|
||||
var item ConfigItem
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v := defaul[0]
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
|
||||
func GetAs[T any](key string, defaul ...any) (T, error) {
|
||||
var t T
|
||||
@@ -160,8 +180,9 @@ func GetManyAs[T any]() (*T, error) {
|
||||
}
|
||||
|
||||
defaultTag := field.Tag.Get("default")
|
||||
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
|
||||
// 检查是否显式定义了 default tag (即使值为空)
|
||||
_, hasDefault := field.Tag.Lookup("default")
|
||||
_, hasDefault = field.Tag.Lookup("default")
|
||||
|
||||
fields = append(fields, fieldInfo{
|
||||
index: i,
|
||||
@@ -406,6 +427,76 @@ func Set(key string, value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMany 将结构体保存为多个配置项
|
||||
func SetManyAs[T any](config T) error {
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
var items []ConfigItem
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
tag := fieldType.Tag.Get("json")
|
||||
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := val.Field(i).Interface()
|
||||
|
||||
bytes, err := json.Marshal(fieldValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
|
||||
}
|
||||
|
||||
items = append(items, ConfigItem{
|
||||
Key: tag,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetMany(cst map[string]any) error {
|
||||
var items []ConfigItem
|
||||
for k, v := range cst {
|
||||
|
||||
+39
-29
@@ -16,20 +16,22 @@ type Settings struct {
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" default:"false"`
|
||||
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
|
||||
// OAuth 配置
|
||||
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
|
||||
OAuthProvider string `json:"o_auth_provider" default:"github"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
|
||||
OAuthProvider string `json:"o_auth_provider" default:"github"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
|
||||
CloudflareTunnelToken string `json:"cloudflare_tunnel_token" default:""`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" default:""`
|
||||
CustomBody string `json:"custom_body" default:""`
|
||||
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" default:"none"`
|
||||
@@ -38,33 +40,38 @@ type Settings struct {
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
|
||||
UpdatedAt time.Time
|
||||
// Record
|
||||
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
|
||||
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
|
||||
CorsAllowedOriginsKey = "cors_allowed_origins"
|
||||
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
|
||||
WsAllowedOriginsKey = "ws_allowed_origins"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
VisitorAuditEnabledKey = "visitor_audit_enabled"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
BaseScriptsURLKey = "base_scripts_url"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
|
||||
CorsAllowedOriginsKey = "cors_allowed_origins"
|
||||
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
|
||||
WsAllowedOriginsKey = "ws_allowed_origins"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
BaseScriptsURLKey = "base_scripts_url"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
NezhaCompatEnabledKey = "nezha_compat_enabled"
|
||||
NezhaCompatListenKey = "nezha_compat_listen"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CloudflareTunnelTokenKey = "cloudflare_tunnel_token"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
NotificationEnabledKey = "notification_enabled"
|
||||
NotificationMethodKey = "notification_method"
|
||||
NotificationTemplateKey = "notification_template"
|
||||
@@ -72,6 +79,9 @@ const (
|
||||
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
RecordEnabledKey = "record_enabled"
|
||||
RecordPreserveTimeKey = "record_preserve_time"
|
||||
PingRecordPreserveTimeKey = "ping_record_preserve_time"
|
||||
UpdatedAtKey = "updated_at"
|
||||
XtermjsSettingsKey = "xtermjs_settings"
|
||||
)
|
||||
+1
-2
@@ -40,7 +40,7 @@ type cronSchedule struct {
|
||||
}
|
||||
|
||||
func (s cronSchedule) Next(t time.Time) time.Time {
|
||||
next := t.UTC().Truncate(time.Second).Add(time.Second)
|
||||
next := t.Truncate(time.Second).Add(time.Second)
|
||||
limit := next.Add(366 * 24 * time.Hour)
|
||||
for next.Before(limit) {
|
||||
if s.match(next) {
|
||||
@@ -52,7 +52,6 @@ func (s cronSchedule) Next(t time.Time) time.Time {
|
||||
}
|
||||
|
||||
func (s cronSchedule) match(t time.Time) bool {
|
||||
t = t.In(time.Local)
|
||||
_, okSecond := s.seconds[t.Second()]
|
||||
_, okMinute := s.minutes[t.Minute()]
|
||||
_, okHour := s.hours[t.Hour()]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package corn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCronScheduleUsesSystemLocalWallClock(t *testing.T) {
|
||||
originalLocal := time.Local
|
||||
time.Local = time.FixedZone("UTC+8", 8*60*60)
|
||||
t.Cleanup(func() { time.Local = originalLocal })
|
||||
|
||||
schedule, err := Parse("0 0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatalf("parse schedule: %v", err)
|
||||
}
|
||||
after := time.Date(2026, 7, 17, 0, 30, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 7, 17, 1, 0, 0, 0, time.UTC)
|
||||
if got := schedule.Next(after); !got.Equal(want) {
|
||||
t.Fatalf("next run = %s, want %s", got, want)
|
||||
} else if got.Location() != time.UTC {
|
||||
t.Fatalf("next run location = %s, want UTC", got.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEverySchedulePreservesElapsedDuration(t *testing.T) {
|
||||
schedule, err := Parse("@every 90s")
|
||||
if err != nil {
|
||||
t.Fatalf("parse schedule: %v", err)
|
||||
}
|
||||
after := time.Now()
|
||||
if got := schedule.Next(after); got.Sub(after) != 90*time.Second {
|
||||
t.Fatalf("interval = %s, want 90s", got.Sub(after))
|
||||
}
|
||||
}
|
||||
+36
-71
@@ -7,17 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type aggregateSeriesKey struct {
|
||||
metricName string
|
||||
entityID string
|
||||
tagsHash string
|
||||
}
|
||||
|
||||
type aggregateGroupKey struct {
|
||||
aggregateSeriesKey
|
||||
bucket int64
|
||||
}
|
||||
|
||||
// AggregatePoints groups raw points into time buckets and computes aggregate values.
|
||||
//
|
||||
// AggregatePoints 在内存中按查询配置将原始点分桶并聚合。
|
||||
@@ -26,88 +15,49 @@ func AggregatePoints(points []Point, query AggregateQuery) ([]AggregatePoint, er
|
||||
return nil, err
|
||||
}
|
||||
if len(points) == 0 {
|
||||
if query.FillEmpty {
|
||||
return emptyBuckets(query), nil
|
||||
}
|
||||
return []AggregatePoint{}, nil
|
||||
}
|
||||
|
||||
points = sortedPoints(points)
|
||||
groups := make(map[aggregateGroupKey][]Point)
|
||||
seriesTags := make(map[aggregateSeriesKey]map[string]string)
|
||||
groups := make(map[int64][]Point)
|
||||
for _, point := range points {
|
||||
point = point.normalized()
|
||||
if point.MetricName == "" {
|
||||
point.MetricName = query.MetricName
|
||||
}
|
||||
if point.EntityID == "" {
|
||||
point.EntityID = query.EntityID
|
||||
}
|
||||
tagsHash, _, err := tagsFingerprint(point.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket := alignTime(point.Timestamp, query.Interval).UnixNano()
|
||||
series := aggregateSeriesKey{
|
||||
metricName: point.MetricName,
|
||||
entityID: point.EntityID,
|
||||
tagsHash: tagsHash,
|
||||
}
|
||||
key := aggregateGroupKey{aggregateSeriesKey: series, bucket: bucket}
|
||||
groups[key] = append(groups[key], point)
|
||||
if _, ok := seriesTags[series]; !ok {
|
||||
seriesTags[series] = cloneStringMap(point.Tags)
|
||||
}
|
||||
groups[bucket] = append(groups[bucket], point)
|
||||
}
|
||||
|
||||
keys := make([]aggregateGroupKey, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
var buckets []int64
|
||||
if query.FillEmpty {
|
||||
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
|
||||
buckets = append(buckets, t.UnixNano())
|
||||
}
|
||||
} else {
|
||||
for bucket := range groups {
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool { return buckets[i] < buckets[j] })
|
||||
}
|
||||
sortAggregateGroupKeys(keys)
|
||||
|
||||
out := make([]AggregatePoint, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
out := make([]AggregatePoint, 0, len(buckets))
|
||||
for _, bucket := range buckets {
|
||||
group := groups[bucket]
|
||||
value, err := aggregateValue(group, query.Aggregation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: key.metricName,
|
||||
EntityID: key.entityID,
|
||||
Bucket: time.Unix(0, key.bucket).UTC(),
|
||||
MetricName: query.MetricName,
|
||||
EntityID: query.EntityID,
|
||||
Bucket: time.Unix(0, bucket).UTC(),
|
||||
Value: value,
|
||||
Count: len(group),
|
||||
Tags: cloneStringMap(seriesTags[key.aggregateSeriesKey]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortAggregateGroupKeys(keys []aggregateGroupKey) {
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].bucket != keys[j].bucket {
|
||||
return keys[i].bucket < keys[j].bucket
|
||||
}
|
||||
if keys[i].metricName != keys[j].metricName {
|
||||
return keys[i].metricName < keys[j].metricName
|
||||
}
|
||||
if keys[i].entityID != keys[j].entityID {
|
||||
return keys[i].entityID < keys[j].entityID
|
||||
}
|
||||
return keys[i].tagsHash < keys[j].tagsHash
|
||||
})
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CalculateStats computes summary statistics for a point series.
|
||||
//
|
||||
// CalculateStats 基于一组点计算统计摘要,包括均值、百分位、首尾值和标准差。
|
||||
@@ -303,3 +253,18 @@ func alignTime(t time.Time, interval time.Duration) time.Time {
|
||||
rem := ((nano % size) + size) % size
|
||||
return time.Unix(0, nano-rem).UTC()
|
||||
}
|
||||
|
||||
// emptyBuckets builds zero-count aggregate buckets for an empty range.
|
||||
//
|
||||
// emptyBuckets 根据查询范围生成空聚合桶,用于 FillEmpty 场景。
|
||||
func emptyBuckets(query AggregateQuery) []AggregatePoint {
|
||||
var out []AggregatePoint
|
||||
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: query.MetricName,
|
||||
EntityID: query.EntityID,
|
||||
Bucket: t,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package metric
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// compactionWatermark returns the last boundary for which a metric's raw data
|
||||
// was successfully materialized into rollups and retained/deleted in the same
|
||||
// transaction. A missing row is expected for stores created before watermark
|
||||
// tracking was introduced or for metrics that have never been compacted.
|
||||
func (s *Store) compactionWatermark(ctx context.Context, metricName string) (time.Time, bool, error) {
|
||||
var nano int64
|
||||
err := s.reader().QueryRowContext(ctx, fmt.Sprintf(
|
||||
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
|
||||
s.tables.watermarks, s.dialect.placeholder(1),
|
||||
), metricName).Scan(&nano)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
if nano <= 0 {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Unix(0, nano).UTC(), true, nil
|
||||
}
|
||||
|
||||
// persistCompactionWatermarkTx records a successful compaction boundary in the
|
||||
// same transaction as rollup writes and raw retention deletion. It is
|
||||
// monotonic so a delayed caller cannot move the read boundary backwards.
|
||||
func (s *Store) persistCompactionWatermarkTx(ctx context.Context, metricName string, watermark time.Time, tx *sql.Tx) error {
|
||||
watermark = watermark.UTC()
|
||||
if watermark.IsZero() {
|
||||
return s.clearCompactionWatermarkTx(ctx, metricName, tx)
|
||||
}
|
||||
|
||||
var previous int64
|
||||
err := tx.QueryRowContext(ctx, fmt.Sprintf(
|
||||
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
|
||||
s.tables.watermarks, s.dialect.placeholder(1),
|
||||
), metricName).Scan(&previous)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil && previous > watermark.UnixNano() {
|
||||
watermark = time.Unix(0, previous).UTC()
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, s.dialect.upsertCompactionWatermarkSQL(s.tables),
|
||||
metricName, watermark.UnixNano(), time.Now().UTC().UnixNano(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) clearCompactionWatermarkTx(ctx context.Context, metricName string, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, fmt.Sprintf(
|
||||
`DELETE FROM %s WHERE metric_name = %s`,
|
||||
s.tables.watermarks, s.dialect.placeholder(1),
|
||||
), metricName)
|
||||
return err
|
||||
}
|
||||
+25
-8
@@ -33,6 +33,10 @@ type Config struct {
|
||||
//
|
||||
// TablePrefix 是 Store 管理的所有表名前缀。
|
||||
TablePrefix string
|
||||
// DefaultRetentionDays is used when a metric definition omits retention.
|
||||
//
|
||||
// DefaultRetentionDays 在指标定义未设置保留时间时使用。
|
||||
DefaultRetentionDays int
|
||||
// AutoMigrate controls whether Open creates or updates the schema.
|
||||
//
|
||||
// AutoMigrate 控制 Open 是否创建或更新表结构。
|
||||
@@ -147,14 +151,15 @@ type Option func(*Config)
|
||||
// DefaultConfig 返回指定后端的默认配置。
|
||||
func DefaultConfig(driver Driver, dsn string) Config {
|
||||
return Config{
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
TablePrefix: "metric_",
|
||||
AutoMigrate: true,
|
||||
MaxOpenConns: 25,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: time.Hour,
|
||||
ConnectTimeout: 10 * time.Second,
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
TablePrefix: "metric_",
|
||||
DefaultRetentionDays: 90,
|
||||
AutoMigrate: true,
|
||||
MaxOpenConns: 25,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: time.Hour,
|
||||
ConnectTimeout: 10 * time.Second,
|
||||
SQLite: SQLiteOptions{
|
||||
PerformanceProfile: SQLiteProfileBalanced,
|
||||
BusyTimeout: 5 * time.Second,
|
||||
@@ -263,6 +268,15 @@ func WithTablePrefix(prefix string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultRetention sets the default metric retention in days.
|
||||
//
|
||||
// WithDefaultRetention 设置指标默认保留天数。
|
||||
func WithDefaultRetention(days int) Option {
|
||||
return func(c *Config) {
|
||||
c.DefaultRetentionDays = days
|
||||
}
|
||||
}
|
||||
|
||||
// WithAutoMigrate controls whether Open creates the schema automatically.
|
||||
//
|
||||
// WithAutoMigrate 控制 Open 时是否自动创建表结构。
|
||||
@@ -428,6 +442,9 @@ func (c Config) Validate() error {
|
||||
return fmt.Errorf("%w: table prefix must contain only letters, digits, and underscores", ErrInvalidArgument)
|
||||
}
|
||||
}
|
||||
if c.DefaultRetentionDays <= 0 {
|
||||
return fmt.Errorf("%w: default retention days must be positive", ErrInvalidArgument)
|
||||
}
|
||||
if err := c.RollupPolicy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestBackendBuildersApplyOptions(t *testing.T) {
|
||||
cfg := SQLite(
|
||||
"file:metrics.db?cache=shared",
|
||||
WithTablePrefix("x_metric_"),
|
||||
WithDefaultRetention(180),
|
||||
WithAutoMigrate(false),
|
||||
WithMaxOpenConns(10),
|
||||
WithMaxIdleConns(2),
|
||||
@@ -25,6 +26,9 @@ func TestBackendBuildersApplyOptions(t *testing.T) {
|
||||
if cfg.TablePrefix != "x_metric_" {
|
||||
t.Fatalf("table prefix option was not applied: %q", cfg.TablePrefix)
|
||||
}
|
||||
if cfg.DefaultRetentionDays != 180 {
|
||||
t.Fatalf("default retention option was not applied: %d", cfg.DefaultRetentionDays)
|
||||
}
|
||||
if cfg.AutoMigrate {
|
||||
t.Fatalf("auto migrate option was not applied")
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ type dialect interface {
|
||||
//
|
||||
// upsertRollupSQL 为单个 rollup 桶单元构造单行 upsert。
|
||||
upsertRollupSQL(t tables) string
|
||||
// upsertCompactionWatermarkSQL builds a single-row upsert for the metric's
|
||||
// persisted compaction watermark.
|
||||
upsertCompactionWatermarkSQL(t tables) string
|
||||
// compactTxOptions returns the transaction options used for the compaction
|
||||
// transaction. PostgreSQL/MySQL escalate to SERIALIZABLE so the raw scan and
|
||||
// the raw deletion share one snapshot and a point inserted between them
|
||||
@@ -74,10 +71,6 @@ type tables struct {
|
||||
//
|
||||
// rollups 是降采样 rollup 表。
|
||||
rollups string
|
||||
// watermarks stores the last successfully compacted raw boundary per metric.
|
||||
//
|
||||
// watermarks 保存每个指标最近一次成功压缩到的 raw 边界。
|
||||
watermarks string
|
||||
}
|
||||
|
||||
// newDialect returns the SQL dialect implementation for a backend.
|
||||
@@ -151,16 +144,6 @@ func (sqliteDialect) upsertPointSQL(t tables, rowCount int) string {
|
||||
created_at = excluded.created_at`)
|
||||
}
|
||||
|
||||
// upsertCompactionWatermarkSQL builds the SQLite watermark upsert.
|
||||
func (sqliteDialect) upsertCompactionWatermarkSQL(t tables) string {
|
||||
return fmt.Sprintf(`INSERT INTO %s
|
||||
(metric_name, watermark_nano, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(metric_name) DO UPDATE SET
|
||||
watermark_nano = excluded.watermark_nano,
|
||||
updated_at = excluded.updated_at`, t.watermarks)
|
||||
}
|
||||
|
||||
// mysqlDialect implements SQL rendering for MySQL.
|
||||
//
|
||||
// mysqlDialect 实现 MySQL 方言。
|
||||
@@ -218,16 +201,6 @@ func (mysqlDialect) upsertPointSQL(t tables, rowCount int) string {
|
||||
created_at = VALUES(created_at)`)
|
||||
}
|
||||
|
||||
// upsertCompactionWatermarkSQL builds the MySQL watermark upsert.
|
||||
func (mysqlDialect) upsertCompactionWatermarkSQL(t tables) string {
|
||||
return fmt.Sprintf(`INSERT INTO %s
|
||||
(metric_name, watermark_nano, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
watermark_nano = VALUES(watermark_nano),
|
||||
updated_at = VALUES(updated_at)`, t.watermarks)
|
||||
}
|
||||
|
||||
// postgresDialect implements SQL rendering for PostgreSQL.
|
||||
//
|
||||
// postgresDialect 实现 PostgreSQL 方言。
|
||||
@@ -285,16 +258,6 @@ func (postgresDialect) upsertPointSQL(t tables, rowCount int) string {
|
||||
created_at = EXCLUDED.created_at`)
|
||||
}
|
||||
|
||||
// upsertCompactionWatermarkSQL builds the PostgreSQL watermark upsert.
|
||||
func (postgresDialect) upsertCompactionWatermarkSQL(t tables) string {
|
||||
return fmt.Sprintf(`INSERT INTO %s
|
||||
(metric_name, watermark_nano, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(metric_name) DO UPDATE SET
|
||||
watermark_nano = EXCLUDED.watermark_nano,
|
||||
updated_at = EXCLUDED.updated_at`, t.watermarks)
|
||||
}
|
||||
|
||||
// buildInsertPointsSQL builds a multi-row insert statement for metric points.
|
||||
//
|
||||
// buildInsertPointsSQL 构造多行采样点 INSERT/UPSERT SQL。
|
||||
|
||||
+14
-14
@@ -32,10 +32,10 @@ func Example_rollupTags() {
|
||||
defer store.Close()
|
||||
|
||||
if err := store.CreateMetric(ctx, metric.Definition{
|
||||
Name: "gpu.usage",
|
||||
Type: metric.TypeGauge,
|
||||
Unit: "%",
|
||||
RetentionDays: 30}); err != nil {
|
||||
Name: "gpu.usage",
|
||||
Type: metric.TypeGauge,
|
||||
Unit: "%",
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -117,15 +117,15 @@ func Example_agentFleetLifecycle() {
|
||||
defer store.Close()
|
||||
|
||||
for _, def := range []metric.Definition{
|
||||
{Name: "cpu.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
|
||||
{Name: "mem.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
|
||||
{Name: "disk.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
|
||||
{Name: "swap.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
|
||||
{Name: "net.in.bytes", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 30},
|
||||
{Name: "net.out.bytes", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 30},
|
||||
{Name: "process.count", Type: metric.TypeGauge, Unit: "count", RetentionDays: 30},
|
||||
{Name: "gpu.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
|
||||
{Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms", RetentionDays: 30},
|
||||
{Name: "cpu.usage", Type: metric.TypeGauge, Unit: "%"},
|
||||
{Name: "mem.usage", Type: metric.TypeGauge, Unit: "%"},
|
||||
{Name: "disk.usage", Type: metric.TypeGauge, Unit: "%"},
|
||||
{Name: "swap.usage", Type: metric.TypeGauge, Unit: "%"},
|
||||
{Name: "net.in.bytes", Type: metric.TypeCounter, Unit: "bytes"},
|
||||
{Name: "net.out.bytes", Type: metric.TypeCounter, Unit: "bytes"},
|
||||
{Name: "process.count", Type: metric.TypeGauge, Unit: "count"},
|
||||
{Name: "gpu.usage", Type: metric.TypeGauge, Unit: "%"},
|
||||
{Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms"},
|
||||
} {
|
||||
if err := store.UpsertMetric(ctx, def); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -158,7 +158,7 @@ func Example_agentFleetLifecycle() {
|
||||
}
|
||||
if err := store.UpsertMetric(ctx, metric.Definition{
|
||||
Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms",
|
||||
RetentionDays: 30}); err != nil {
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case 85:
|
||||
|
||||
@@ -28,7 +28,7 @@ func newMemStore(t *testing.T) *Store {
|
||||
func TestCreateMetricRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
def := Definition{Name: "dup.metric", Type: TypeGauge, RetentionDays: 30}
|
||||
def := Definition{Name: "dup.metric", Type: TypeGauge}
|
||||
if err := s.CreateMetric(ctx, def); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
@@ -44,10 +44,10 @@ func TestCreateMetricRejectsDuplicate(t *testing.T) {
|
||||
func TestUpsertMetricOverwrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, Unit: "ms", RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, Unit: "ms"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, Definition{Name: "m", Type: TypeCounter, Unit: "count", RetentionDays: 30}); err != nil {
|
||||
if err := s.UpsertMetric(ctx, Definition{Name: "m", Type: TypeCounter, Unit: "count"}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
got, err := s.GetMetric(ctx, "m")
|
||||
@@ -65,7 +65,7 @@ func TestUpsertMetricOverwrites(t *testing.T) {
|
||||
func TestTagFilterPushdownWithPaging(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "req", Type: TypeCounter, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "req", Type: TypeCounter}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -110,7 +110,7 @@ func TestTagFilterPushdownWithPaging(t *testing.T) {
|
||||
func TestSQLAggregateMatchesInMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "g", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "g", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -195,7 +195,7 @@ func TestAlignTimeNegativeTimestamp(t *testing.T) {
|
||||
func TestLatestReturnsMostRecent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "l", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "l", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -228,7 +228,7 @@ func TestStatsDistinguishesNoDataFromUnknownMetric(t *testing.T) {
|
||||
}
|
||||
|
||||
// Known metric but empty window -> ErrNoData.
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
_, err = s.Stats(ctx, Query{MetricName: "known", Start: base, End: base.Add(time.Hour)})
|
||||
@@ -266,7 +266,7 @@ func TestStdDevPopMatchesCalculateStats(t *testing.T) {
|
||||
func TestAggregateStdDevSQLiteUsesMemoryPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sd", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sd", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -342,7 +342,7 @@ func TestSQLiteReadPoolOpens(t *testing.T) {
|
||||
t.Fatalf("reader() should return the dedicated read pool")
|
||||
}
|
||||
// Round-trip a write (primary) and a read (read pool).
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "rp", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "rp", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -379,7 +379,7 @@ func TestMemoryDSNSkipsReadPool(t *testing.T) {
|
||||
func TestWriteBatchAtomicAcrossChunks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "atom", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "atom", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -409,7 +409,7 @@ func TestWriteBatchAtomicAcrossChunks(t *testing.T) {
|
||||
func TestAggregateBucketPagingSQLPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "bp", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "bp", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -447,7 +447,7 @@ func TestAggregateBucketPagingSQLPath(t *testing.T) {
|
||||
func TestAggregateBucketPagingMemoryPathMatchesSQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "bp2", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "bp2", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -485,68 +485,13 @@ func TestAggregateBucketPagingMemoryPathMatchesSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateSeparatesTagSeries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "tagged.util", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
points := []Point{
|
||||
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 10, Tags: map[string]string{"device": "0"}},
|
||||
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 30, Tags: map[string]string{"device": "0"}},
|
||||
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 100, Tags: map[string]string{"device": "1"}},
|
||||
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 200, Tags: map[string]string{"device": "1"}},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
query := Query{MetricName: "tagged.util", EntityID: "n1", Start: base, End: base.Add(time.Minute)}
|
||||
|
||||
sqlRes, err := s.Aggregate(ctx, AggregateQuery{
|
||||
Query: query,
|
||||
Aggregation: AggAvg,
|
||||
Interval: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sql aggregate: %v", err)
|
||||
}
|
||||
assertTaggedAggregate(t, sqlRes, "0", 20, 2)
|
||||
assertTaggedAggregate(t, sqlRes, "1", 150, 2)
|
||||
|
||||
memRes, err := s.Aggregate(ctx, AggregateQuery{
|
||||
Query: query,
|
||||
Aggregation: AggP50,
|
||||
Interval: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("memory aggregate: %v", err)
|
||||
}
|
||||
assertTaggedAggregate(t, memRes, "0", 20, 2)
|
||||
assertTaggedAggregate(t, memRes, "1", 150, 2)
|
||||
}
|
||||
|
||||
func assertTaggedAggregate(t *testing.T, points []AggregatePoint, device string, wantValue float64, wantCount int) {
|
||||
t.Helper()
|
||||
for _, point := range points {
|
||||
if point.Tags["device"] != device {
|
||||
continue
|
||||
}
|
||||
if point.Value != wantValue || point.Count != wantCount {
|
||||
t.Fatalf("device %s aggregate = value %v count %d, want value %v count %d; all=%#v", device, point.Value, point.Count, wantValue, wantCount, points)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing aggregate for device %s; all=%#v", device, points)
|
||||
}
|
||||
|
||||
// TestAggregateIgnoresRawPointLimit verifies raw limits do not affect aggregation.
|
||||
//
|
||||
// TestAggregateIgnoresRawPointLimit 验证原始点分页参数不会影响聚合输入。
|
||||
func TestAggregateIgnoresRawPointLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "ig", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "ig", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -578,7 +523,7 @@ func TestAggregateIgnoresRawPointLimit(t *testing.T) {
|
||||
func TestJSONTagKeyWithSpecialChars(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "tk", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "tk", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package metric
|
||||
|
||||
import "time"
|
||||
|
||||
var standardQueryIntervals = [...]time.Duration{
|
||||
time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
15 * time.Second,
|
||||
30 * time.Second,
|
||||
time.Minute,
|
||||
2 * time.Minute,
|
||||
5 * time.Minute,
|
||||
10 * time.Minute,
|
||||
15 * time.Minute,
|
||||
30 * time.Minute,
|
||||
time.Hour,
|
||||
2 * time.Hour,
|
||||
3 * time.Hour,
|
||||
6 * time.Hour,
|
||||
12 * time.Hour,
|
||||
24 * time.Hour,
|
||||
}
|
||||
|
||||
// FloorStandardInterval returns the largest standard query interval that does
|
||||
// not exceed interval. Values below one second use the minimum interval.
|
||||
func FloorStandardInterval(interval time.Duration) time.Duration {
|
||||
selected := standardQueryIntervals[0]
|
||||
for _, candidate := range standardQueryIntervals {
|
||||
if candidate > interval {
|
||||
break
|
||||
}
|
||||
selected = candidate
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// CeilStandardInterval returns the smallest standard query interval that is
|
||||
// at least interval. Values above one day are rounded up to whole days.
|
||||
func CeilStandardInterval(interval time.Duration) time.Duration {
|
||||
for _, candidate := range standardQueryIntervals {
|
||||
if candidate >= interval {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
day := standardQueryIntervals[len(standardQueryIntervals)-1]
|
||||
return ((interval-1)/day + 1) * day
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package metric
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStandardIntervals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
floor time.Duration
|
||||
ceil time.Duration
|
||||
}{
|
||||
{name: "below minimum", interval: 500 * time.Millisecond, floor: time.Second, ceil: time.Second},
|
||||
{name: "exact", interval: 5 * time.Minute, floor: 5 * time.Minute, ceil: 5 * time.Minute},
|
||||
{name: "between standards", interval: 7 * time.Minute, floor: 5 * time.Minute, ceil: 10 * time.Minute},
|
||||
{name: "above one day", interval: 25 * time.Hour, floor: 24 * time.Hour, ceil: 48 * time.Hour},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := FloorStandardInterval(test.interval); got != test.floor {
|
||||
t.Fatalf("FloorStandardInterval(%s) = %s, want %s", test.interval, got, test.floor)
|
||||
}
|
||||
if got := CeilStandardInterval(test.interval); got != test.ceil {
|
||||
t.Fatalf("CeilStandardInterval(%s) = %s, want %s", test.interval, got, test.ceil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package metric
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaintenanceAction identifies the backend-specific operation used to reclaim
|
||||
// physical database space.
|
||||
//
|
||||
// MaintenanceAction 表示回收数据库物理空间时使用的后端专用操作。
|
||||
type MaintenanceAction string
|
||||
|
||||
const (
|
||||
// MaintenanceVacuum is SQLite's VACUUM operation.
|
||||
MaintenanceVacuum MaintenanceAction = "vacuum"
|
||||
// MaintenanceOptimize is MySQL's OPTIMIZE TABLE operation.
|
||||
MaintenanceOptimize MaintenanceAction = "optimize"
|
||||
// MaintenanceVacuumFull is PostgreSQL's blocking VACUUM FULL operation.
|
||||
MaintenanceVacuumFull MaintenanceAction = "vacuum_full"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteCheckpointSQL = "PRAGMA wal_checkpoint(TRUNCATE)"
|
||||
sqliteVacuumSQL = "VACUUM"
|
||||
)
|
||||
|
||||
// Driver returns the Store's configured database backend.
|
||||
//
|
||||
// Driver 返回 Store 配置的数据库后端。
|
||||
func (s *Store) Driver() Driver {
|
||||
return s.cfg.Driver
|
||||
}
|
||||
|
||||
// MaintenanceAction returns the physical-space reclamation operation used by
|
||||
// the Store's backend.
|
||||
//
|
||||
// MaintenanceAction 返回当前后端用于回收物理空间的操作。
|
||||
func (s *Store) MaintenanceAction() MaintenanceAction {
|
||||
return maintenanceActionFor(s.cfg.Driver)
|
||||
}
|
||||
|
||||
// StorageSize returns the physical bytes occupied by this Store. SQLite
|
||||
// includes the main database, WAL, and shared-memory files. Server backends
|
||||
// include only the definitions, points, and rollups tables managed by Store.
|
||||
//
|
||||
// StorageSize 返回当前 Store 占用的物理字节数。SQLite 会统计主数据库、WAL
|
||||
// 和共享内存文件;服务端数据库只统计 Store 管理的 definitions、points 和
|
||||
// rollups 三张表。
|
||||
func (s *Store) StorageSize(ctx context.Context) (int64, error) {
|
||||
s.maintenanceMu.RLock()
|
||||
defer s.maintenanceMu.RUnlock()
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.closed || s.db == nil {
|
||||
return 0, ErrClosed
|
||||
}
|
||||
|
||||
if s.cfg.Driver == DriverSQLite {
|
||||
return s.sqliteStorageSize(ctx)
|
||||
}
|
||||
|
||||
query, args, err := managedStorageSizeQuery(s.cfg.Driver, s.tables)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var size int64
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&size); err != nil {
|
||||
return 0, fmt.Errorf("metric: query %s storage size: %w", s.cfg.Driver, err)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// ReclaimSpace performs the backend-specific blocking operation that returns
|
||||
// unused database pages to the filesystem. It serializes against other
|
||||
// maintenance calls and keeps Close from closing the pool mid-operation.
|
||||
//
|
||||
// ReclaimSpace 执行后端专用的阻塞式空间回收操作。该方法会与其他维护调用
|
||||
// 串行执行,并阻止 Close 在操作过程中关闭连接池。
|
||||
func (s *Store) ReclaimSpace(ctx context.Context) error {
|
||||
s.maintenanceMu.Lock()
|
||||
defer s.maintenanceMu.Unlock()
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.closed || s.db == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
switch s.cfg.Driver {
|
||||
case DriverSQLite:
|
||||
if err := sqliteCheckpoint(ctx, s.db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, sqliteVacuumSQL); err != nil {
|
||||
return fmt.Errorf("metric: vacuum sqlite database: %w", err)
|
||||
}
|
||||
// VACUUM itself can populate the WAL; truncate it again so the reported
|
||||
// physical size reflects the completed reclamation.
|
||||
return sqliteCheckpoint(ctx, s.db)
|
||||
case DriverMySQL:
|
||||
query, err := managedReclaimQuery(s.cfg.Driver, s.tables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.optimizeMySQLTables(ctx, query)
|
||||
case DriverPostgreSQL:
|
||||
query, err := managedReclaimQuery(s.cfg.Driver, s.tables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, query); err != nil {
|
||||
return fmt.Errorf("metric: vacuum PostgreSQL metric tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported driver %q", ErrInvalidArgument, s.cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) sqliteStorageSize(ctx context.Context) (int64, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "PRAGMA database_list")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("metric: list sqlite databases: %w", err)
|
||||
}
|
||||
|
||||
var path string
|
||||
for rows.Next() {
|
||||
var sequence int
|
||||
var name, file string
|
||||
if err := rows.Scan(&sequence, &name, &file); err != nil {
|
||||
_ = rows.Close()
|
||||
return 0, fmt.Errorf("metric: scan sqlite database path: %w", err)
|
||||
}
|
||||
if name == "main" {
|
||||
path = file
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return 0, fmt.Errorf("metric: list sqlite databases: %w", err)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, fmt.Errorf("metric: close sqlite database list: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
// SQLite reports an empty path for in-memory databases.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var size int64
|
||||
for _, name := range []string{path, path + "-wal", path + "-shm"} {
|
||||
info, err := os.Stat(name)
|
||||
switch {
|
||||
case err == nil:
|
||||
size += info.Size()
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
default:
|
||||
return 0, fmt.Errorf("metric: stat sqlite storage file %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func sqliteCheckpoint(ctx context.Context, db *sql.DB) error {
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRowContext(ctx, sqliteCheckpointSQL).Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
return fmt.Errorf("metric: checkpoint sqlite WAL: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf("metric: checkpoint sqlite WAL: database is busy (%d log frames, %d checkpointed)", logFrames, checkpointedFrames)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) optimizeMySQLTables(ctx context.Context, query string) error {
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metric: optimize MySQL metric tables: %w", err)
|
||||
}
|
||||
|
||||
var resultErrors []error
|
||||
for rows.Next() {
|
||||
var table, operation, messageType, message string
|
||||
if err := rows.Scan(&table, &operation, &messageType, &message); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("metric: scan MySQL optimize result: %w", err)
|
||||
}
|
||||
if err := mysqlOptimizeResultError(table, messageType, message); err != nil {
|
||||
resultErrors = append(resultErrors, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("metric: read MySQL optimize results: %w", err)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("metric: close MySQL optimize results: %w", err)
|
||||
}
|
||||
return errors.Join(resultErrors...)
|
||||
}
|
||||
|
||||
func mysqlOptimizeResultError(table, messageType, message string) error {
|
||||
if !strings.EqualFold(strings.TrimSpace(messageType), "error") {
|
||||
return nil
|
||||
}
|
||||
table = strings.TrimSpace(table)
|
||||
if table == "" {
|
||||
table = "unknown table"
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
message = "unknown error"
|
||||
}
|
||||
return fmt.Errorf("metric: optimize MySQL table %s: %s", table, message)
|
||||
}
|
||||
|
||||
func maintenanceActionFor(driver Driver) MaintenanceAction {
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
return MaintenanceVacuum
|
||||
case DriverMySQL:
|
||||
return MaintenanceOptimize
|
||||
case DriverPostgreSQL:
|
||||
return MaintenanceVacuumFull
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func managedStorageSizeQuery(driver Driver, t tables) (string, []any, error) {
|
||||
names := managedTableNames(t)
|
||||
d := newDialect(driver)
|
||||
placeholders := make([]string, len(names))
|
||||
for i := range names {
|
||||
placeholders[i] = d.placeholder(i + 1)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ", ")
|
||||
switch driver {
|
||||
case DriverMySQL:
|
||||
return fmt.Sprintf(`SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (%s)`, inClause), stringsToAny(names), nil
|
||||
case DriverPostgreSQL:
|
||||
for i := range names {
|
||||
names[i] = strings.ToLower(names[i])
|
||||
}
|
||||
return fmt.Sprintf(`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)
|
||||
FROM pg_catalog.pg_class AS c
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relname IN (%s)`, inClause), stringsToAny(names), nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("%w: storage-size query is unavailable for driver %q", ErrInvalidArgument, driver)
|
||||
}
|
||||
}
|
||||
|
||||
func managedReclaimQuery(driver Driver, t tables) (string, error) {
|
||||
names := managedTableNames(t)
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
return sqliteVacuumSQL, nil
|
||||
case DriverMySQL:
|
||||
for i := range names {
|
||||
names[i] = quoteMaintenanceIdentifier(driver, names[i])
|
||||
}
|
||||
return "OPTIMIZE TABLE " + strings.Join(names, ", "), nil
|
||||
case DriverPostgreSQL:
|
||||
for i := range names {
|
||||
names[i] = quoteMaintenanceIdentifier(driver, strings.ToLower(names[i]))
|
||||
}
|
||||
return "VACUUM (FULL, ANALYZE) " + strings.Join(names, ", "), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: reclaim query is unavailable for driver %q", ErrInvalidArgument, driver)
|
||||
}
|
||||
}
|
||||
|
||||
func managedTableNames(t tables) []string {
|
||||
names := []string{t.definitions, t.points, t.rollups}
|
||||
if t.watermarks != "" {
|
||||
names = append(names, t.watermarks)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func quoteMaintenanceIdentifier(driver Driver, identifier string) string {
|
||||
if driver == DriverMySQL {
|
||||
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
||||
}
|
||||
return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func stringsToAny(values []string) []any {
|
||||
args := make([]any, len(values))
|
||||
for i := range values {
|
||||
args[i] = values[i]
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package metric
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteStorageSizeAndReclaimSpace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
store, err := Open(ctx, SQLiteInDir(dir, WithSQLiteWALAutoCheckpoint(1_000_000)))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
if got := store.Driver(); got != DriverSQLite {
|
||||
t.Fatalf("Driver() = %q, want %q", got, DriverSQLite)
|
||||
}
|
||||
if got := store.MaintenanceAction(); got != MaintenanceVacuum {
|
||||
t.Fatalf("MaintenanceAction() = %q, want %q", got, MaintenanceVacuum)
|
||||
}
|
||||
|
||||
if _, err := store.db.ExecContext(ctx, `CREATE TABLE reclaim_fixture (payload BLOB NOT NULL)`); err != nil {
|
||||
t.Fatalf("create reclaim fixture: %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `INSERT INTO reclaim_fixture (payload) VALUES (zeroblob(4194304))`); err != nil {
|
||||
t.Fatalf("populate reclaim fixture: %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `DROP TABLE reclaim_fixture`); err != nil {
|
||||
t.Fatalf("drop reclaim fixture: %v", err)
|
||||
}
|
||||
|
||||
before, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("storage size before reclaim: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "metrics.db")
|
||||
if want := sqliteFileSetSize(t, path); before != want {
|
||||
t.Fatalf("StorageSize() = %d, file sum = %d", before, want)
|
||||
}
|
||||
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
t.Fatalf("reclaim sqlite space: %v", err)
|
||||
}
|
||||
after, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("storage size after reclaim: %v", err)
|
||||
}
|
||||
if want := sqliteFileSetSize(t, path); after != want {
|
||||
t.Fatalf("StorageSize() after reclaim = %d, file sum = %d", after, want)
|
||||
}
|
||||
if after >= before {
|
||||
t.Fatalf("reclaim did not reduce physical storage: before=%d after=%d", before, after)
|
||||
}
|
||||
if err := store.Ping(ctx); err != nil {
|
||||
t.Fatalf("store unusable after reclaim: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if _, err := store.StorageSize(ctx); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("StorageSize() after Close error = %v, want ErrClosed", err)
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("ReclaimSpace() after Close error = %v, want ErrClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceMappings(t *testing.T) {
|
||||
tables := tables{
|
||||
definitions: "Metric_definitions",
|
||||
points: "Metric_points",
|
||||
rollups: "Metric_rollups",
|
||||
watermarks: "Metric_compaction_watermarks",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
driver Driver
|
||||
action MaintenanceAction
|
||||
reclaim string
|
||||
sizeParts []string
|
||||
sizeArgs []any
|
||||
hasSizeSQL bool
|
||||
}{
|
||||
{
|
||||
name: "sqlite",
|
||||
driver: DriverSQLite,
|
||||
action: MaintenanceVacuum,
|
||||
reclaim: "VACUUM",
|
||||
hasSizeSQL: false,
|
||||
},
|
||||
{
|
||||
name: "mysql",
|
||||
driver: DriverMySQL,
|
||||
action: MaintenanceOptimize,
|
||||
reclaim: "OPTIMIZE TABLE `Metric_definitions`, `Metric_points`, `Metric_rollups`, `Metric_compaction_watermarks`",
|
||||
sizeParts: []string{"information_schema.TABLES", "TABLE_SCHEMA = DATABASE()", "TABLE_NAME IN (?, ?, ?, ?)"},
|
||||
sizeArgs: []any{"Metric_definitions", "Metric_points", "Metric_rollups", "Metric_compaction_watermarks"},
|
||||
hasSizeSQL: true,
|
||||
},
|
||||
{
|
||||
name: "postgresql",
|
||||
driver: DriverPostgreSQL,
|
||||
action: MaintenanceVacuumFull,
|
||||
reclaim: `VACUUM (FULL, ANALYZE) "metric_definitions", "metric_points", "metric_rollups", "metric_compaction_watermarks"`,
|
||||
sizeParts: []string{"pg_total_relation_size(c.oid)", "n.nspname = current_schema()", "c.relname IN ($1, $2, $3, $4)"},
|
||||
sizeArgs: []any{"metric_definitions", "metric_points", "metric_rollups", "metric_compaction_watermarks"},
|
||||
hasSizeSQL: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := maintenanceActionFor(tt.driver); got != tt.action {
|
||||
t.Fatalf("maintenanceActionFor(%q) = %q, want %q", tt.driver, got, tt.action)
|
||||
}
|
||||
gotReclaim, err := managedReclaimQuery(tt.driver, tables)
|
||||
if err != nil {
|
||||
t.Fatalf("managedReclaimQuery(%q): %v", tt.driver, err)
|
||||
}
|
||||
if gotReclaim != tt.reclaim {
|
||||
t.Fatalf("managedReclaimQuery(%q) = %q, want %q", tt.driver, gotReclaim, tt.reclaim)
|
||||
}
|
||||
|
||||
gotSize, gotArgs, err := managedStorageSizeQuery(tt.driver, tables)
|
||||
if !tt.hasSizeSQL {
|
||||
if err == nil {
|
||||
t.Fatalf("managedStorageSizeQuery(%q) unexpectedly succeeded: %q", tt.driver, gotSize)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("managedStorageSizeQuery(%q): %v", tt.driver, err)
|
||||
}
|
||||
for _, part := range tt.sizeParts {
|
||||
if !strings.Contains(gotSize, part) {
|
||||
t.Errorf("size query for %q does not contain %q: %s", tt.driver, part, gotSize)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(gotArgs, tt.sizeArgs) {
|
||||
t.Fatalf("size args for %q = %#v, want %#v", tt.driver, gotArgs, tt.sizeArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLOptimizeResultError(t *testing.T) {
|
||||
if err := mysqlOptimizeResultError("metric_points", "status", "OK"); err != nil {
|
||||
t.Fatalf("status result returned an error: %v", err)
|
||||
}
|
||||
if err := mysqlOptimizeResultError("metric_points", "note", "recreate and analyze instead"); err != nil {
|
||||
t.Fatalf("note result returned an error: %v", err)
|
||||
}
|
||||
err := mysqlOptimizeResultError("komari.metric_points", " Error ", "operation failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "komari.metric_points") || !strings.Contains(err.Error(), "operation failed") {
|
||||
t.Fatalf("error result was not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteFileSetSize(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
var size int64
|
||||
for _, name := range []string{path, path + "-wal", path + "-shm"} {
|
||||
info, err := os.Stat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("stat %q: %v", name, err)
|
||||
}
|
||||
size += info.Size()
|
||||
}
|
||||
return size
|
||||
}
|
||||
@@ -66,11 +66,6 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
)`, s.tables.rollups, pk, jsonType, blob),
|
||||
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_lookup_idx ON %s (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
|
||||
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_res_time_idx ON %s (metric_name, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
metric_name VARCHAR(191) PRIMARY KEY,
|
||||
watermark_nano BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)`, s.tables.watermarks),
|
||||
}
|
||||
|
||||
if s.cfg.Driver == DriverMySQL {
|
||||
@@ -122,11 +117,6 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
UNIQUE KEY uq_rollup (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano),
|
||||
INDEX idx_rollup_res_time (metric_name, resolution_nano, bucket_nano)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.rollups, pk, jsonType, blob),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
metric_name VARCHAR(191) PRIMARY KEY,
|
||||
watermark_nano BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.watermarks),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,11 +170,6 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
)`, s.tables.rollups, pk, jsonType, blob),
|
||||
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_lookup_idx ON %s (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
|
||||
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_res_time_idx ON %s (metric_name, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
metric_name VARCHAR(191) PRIMARY KEY,
|
||||
watermark_nano BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)`, s.tables.watermarks),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,18 +52,18 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
|
||||
defer dropIntegrationTables(t, store, prefix)
|
||||
|
||||
if err := store.CreateMetric(ctx, Definition{
|
||||
Name: "http.latency",
|
||||
Type: TypeGauge,
|
||||
Unit: "ms",
|
||||
Description: "HTTP latency",
|
||||
Metadata: map[string]string{"source": "integration"},
|
||||
RetentionDays: 30}); err != nil {
|
||||
Name: "http.latency",
|
||||
Type: TypeGauge,
|
||||
Unit: "ms",
|
||||
Description: "HTTP latency",
|
||||
Metadata: map[string]string{"source": "integration"},
|
||||
}); err != nil {
|
||||
t.Fatalf("create metric: %v", err)
|
||||
}
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, RetentionDays: 30}); err == nil {
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge}); err == nil {
|
||||
t.Fatalf("duplicate CreateMetric should fail")
|
||||
}
|
||||
if err := store.UpsertMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, Unit: "milliseconds", RetentionDays: 30}); err != nil {
|
||||
if err := store.UpsertMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, Unit: "milliseconds"}); err != nil {
|
||||
t.Fatalf("upsert metric: %v", err)
|
||||
}
|
||||
def, err := store.GetMetric(ctx, "http.latency")
|
||||
@@ -166,20 +166,6 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
|
||||
if deleted != 2 {
|
||||
t.Fatalf("expected 2 deleted points, got %d", deleted)
|
||||
}
|
||||
|
||||
storageSize, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("storage size: %v", err)
|
||||
}
|
||||
if storageSize <= 0 {
|
||||
t.Fatalf("storage size = %d, want a positive value", storageSize)
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
t.Fatalf("reclaim storage space: %v", err)
|
||||
}
|
||||
if err := store.Ping(ctx); err != nil {
|
||||
t.Fatalf("store unusable after space reclaim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropIntegrationTables drops integration-test tables.
|
||||
|
||||
@@ -89,30 +89,6 @@ func (p RollupPolicy) rawCutoff(now time.Time) time.Time {
|
||||
return time.Unix(0, floorDivNano(cutoff.UnixNano(), interval)).UTC()
|
||||
}
|
||||
|
||||
// withMetricRetention applies the metric definition's retention to a rollup
|
||||
// policy. Fine tiers keep their configured short windows, while the final tier
|
||||
// follows the metric so definitions are never capped by a store-wide setting.
|
||||
// Coarser tiers that do not extend retention are omitted because their buckets
|
||||
// can be derived from the retained finer tier on demand.
|
||||
func (p RollupPolicy) withMetricRetention(retention time.Duration) RollupPolicy {
|
||||
if retention <= 0 || len(p.Tiers) == 0 {
|
||||
return p
|
||||
}
|
||||
|
||||
out := p
|
||||
out.Tiers = make([]RollupTier, 0, len(p.Tiers))
|
||||
for i, tier := range p.Tiers {
|
||||
if i == len(p.Tiers)-1 || tier.Retention > retention {
|
||||
tier.Retention = retention
|
||||
}
|
||||
if len(out.Tiers) > 0 && tier.Retention <= out.Tiers[len(out.Tiers)-1].Retention {
|
||||
continue
|
||||
}
|
||||
out.Tiers = append(out.Tiers, tier)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate enforces the structural rules that make cascading composition and
|
||||
// retention well-defined.
|
||||
//
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSeriesHybridKeepsUncompactedRawNearCutoff(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "nearcut", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "nearcut", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 30, 0, time.UTC)
|
||||
@@ -94,7 +94,7 @@ func TestSeriesHybridEndEqualsCutoff(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "endcut", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "endcut", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -133,52 +133,3 @@ func TestSeriesHybridEndEqualsCutoff(t *testing.T) {
|
||||
t.Fatalf("expected 11:00 bucket including the cutoff raw point: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesHybridUsesLastCompactionWatermark verifies that a query does not
|
||||
// advance the raw/rollup split beyond the last successful compaction. The raw
|
||||
// point at 11:32 is still present after the 12:00 compaction, but a query at
|
||||
// 12:04 would calculate a dynamic cutoff of 11:34 and skip it without the
|
||||
// persisted watermark at 11:30.
|
||||
func TestSeriesHybridUsesLastCompactionWatermark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 30 * time.Minute,
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "watermark", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
compactAt := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
if err := s.WriteBatch(ctx, []Point{
|
||||
{MetricName: "watermark", EntityID: "n1", Timestamp: time.Date(2026, 6, 18, 11, 20, 0, 0, time.UTC), Value: 10},
|
||||
{MetricName: "watermark", EntityID: "n1", Timestamp: time.Date(2026, 6, 18, 11, 32, 0, 0, time.UTC), Value: 100},
|
||||
}); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, compactAt); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
|
||||
queryAt := compactAt.Add(4 * time.Minute)
|
||||
got, err := s.Series(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "watermark", EntityID: "n1", Start: time.Date(2026, 6, 18, 11, 0, 0, 0, time.UTC), End: queryAt},
|
||||
Aggregation: AggCount,
|
||||
Interval: time.Hour,
|
||||
}, queryAt)
|
||||
if err != nil {
|
||||
t.Fatalf("series: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != 2 {
|
||||
t.Fatalf("series should include rollup and raw data before the next compaction, got %#v", got)
|
||||
}
|
||||
|
||||
watermark, found, err := s.compactionWatermark(ctx, "watermark")
|
||||
if err != nil {
|
||||
t.Fatalf("read watermark: %v", err)
|
||||
}
|
||||
if !found || !watermark.Equal(policy.rawCutoff(compactAt)) {
|
||||
t.Fatalf("watermark = %v, found=%v, want %v", watermark, found, policy.rawCutoff(compactAt))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestAggregateRollupExcludesPartialBucket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "win", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "win", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -72,7 +72,7 @@ func TestAggregateRollupEndBoundaryInclusive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "edge", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "edge", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -123,7 +123,7 @@ func TestSeriesHybridMergesStraddlingBucket(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "straddle", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "straddle", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -192,7 +192,7 @@ func TestSeriesHybridSumAcrossBoundary(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sumstraddle", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sumstraddle", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -236,7 +236,7 @@ func TestSeriesHybridIncludesRawBetweenCutoffAndNextBucket(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "cutgap", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "cutgap", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestSeriesHybridEndAtCutoffIncludesRawBoundary(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "cutedge", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "cutedge", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ func TestCompactDoesNotDoubleCountRawBucketAfterCutoffAdvances(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "nodup", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "nodup", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
@@ -345,43 +345,3 @@ func TestCompactDoesNotDoubleCountRawBucketAfterCutoffAdvances(t *testing.T) {
|
||||
t.Fatalf("compaction should not double-count a bucket after cutoff advances, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesHybridIncludesPartialCoarseBucketAtRawCutoff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 10 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 20 * time.Minute},
|
||||
{Interval: time.Hour, Retention: 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "coarse-cutoff", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 6, 18, 12, 50, 0, 0, time.UTC)
|
||||
old := now.Add(-40 * time.Minute)
|
||||
recent := now.Add(-5 * time.Minute)
|
||||
if err := s.WriteBatch(ctx, []Point{
|
||||
{MetricName: "coarse-cutoff", EntityID: "n1", Timestamp: old, Value: 10},
|
||||
{MetricName: "coarse-cutoff", EntityID: "n1", Timestamp: recent, Value: 100},
|
||||
}); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Series(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "coarse-cutoff", EntityID: "n1", Start: now.Add(-2 * time.Hour), End: now},
|
||||
Aggregation: AggAvg,
|
||||
Interval: time.Hour,
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("series: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != 2 || math.Abs(got[0].Value-55) > 1e-9 {
|
||||
t.Fatalf("expected coarse rollup and recent raw point, got %#v", got)
|
||||
}
|
||||
}
|
||||
+80
-202
@@ -17,8 +17,9 @@ import (
|
||||
//
|
||||
// query.Tags is honored: only rollup series whose stored tag set matches the
|
||||
// filter are folded in, so a tag filter selects the same data it would over raw
|
||||
// points. By default, matched series are merged into each output bucket; callers
|
||||
// that need entity/tag identities preserved can set PreserveSeries.
|
||||
// points (each distinct tag combination is its own rollup series). With no tag
|
||||
// filter, all tag series within a bucket merge together, matching the raw path's
|
||||
// cross-tag aggregation.
|
||||
//
|
||||
// Every aggregation works except AggRate, which needs the ordered raw series and
|
||||
// is therefore raw-only. Percentiles (p50, p95, p99, and arbitrary pXX) are
|
||||
@@ -31,8 +32,9 @@ import (
|
||||
// rollup 桶组成。
|
||||
//
|
||||
// query.Tags 会被遵守:只有存储标签集合匹配过滤条件的 rollup 序列会被合入,
|
||||
// 因此标签过滤会选中与原始点查询相同的数据。默认会把匹配序列合并进每个
|
||||
// 输出桶;需要保留 entity/tag 身份的调用方可以设置 PreserveSeries。
|
||||
// 因此标签过滤会选中与原始点查询相同的数据(每个不同标签组合都是自己的
|
||||
// rollup 序列)。没有标签过滤时,同一桶内的所有标签序列会合并在一起,
|
||||
// 与原始路径的跨标签聚合一致。
|
||||
//
|
||||
// 除 AggRate 外,每种聚合都可用;AggRate 需要有序原始序列,因此只能基于原始点。
|
||||
// 百分位(p50、p95、p99 和任意 pXX)通过合并每桶 t-digest 回答,因此能在
|
||||
@@ -70,7 +72,7 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries)
|
||||
groups := foldRollupRows(nil, rows, query.Interval, comp)
|
||||
|
||||
out, err := rollupGroupsToPoints(groups, query)
|
||||
if err != nil {
|
||||
@@ -81,55 +83,36 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
|
||||
|
||||
// rollupGroupsToPoints turns the merged output buckets into ordered
|
||||
// AggregatePoints, computing the requested aggregation from each bucket's
|
||||
// summaries/digest.
|
||||
// summaries/digest. FillEmpty emits zero-count buckets for gaps, mirroring the
|
||||
// raw AggregatePoints behavior.
|
||||
//
|
||||
// rollupGroupsToPoints 将合并后的输出桶转换为有序 AggregatePoint,并根据
|
||||
// 每个桶的摘要或 digest 计算请求的聚合。
|
||||
func rollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
|
||||
if !query.PreserveSeries {
|
||||
return mergedRollupGroupsToPoints(groups, query)
|
||||
}
|
||||
|
||||
keys := make([]rollupKey, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sortRollupKeys(keys)
|
||||
|
||||
out := make([]AggregatePoint, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
b := groups[key]
|
||||
v, ok := b.value(query.Aggregation)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
|
||||
// 每个桶的摘要或 digest 计算请求的聚合。FillEmpty 会为空洞输出零计数桶,
|
||||
// 与原始 AggregatePoints 行为一致。
|
||||
func rollupGroupsToPoints(groups map[int64]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
|
||||
var bucketStarts []int64
|
||||
if query.FillEmpty {
|
||||
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
|
||||
bucketStarts = append(bucketStarts, t.UnixNano())
|
||||
}
|
||||
tags, err := rollupTagsFromJSON(b.tagsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for k := range groups {
|
||||
bucketStarts = append(bucketStarts, k)
|
||||
}
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: query.MetricName,
|
||||
EntityID: key.entityID,
|
||||
Bucket: time.Unix(0, key.bucket).UTC(),
|
||||
Value: v,
|
||||
Count: int(b.count),
|
||||
Tags: tags,
|
||||
})
|
||||
sort.Slice(bucketStarts, func(i, j int) bool { return bucketStarts[i] < bucketStarts[j] })
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
|
||||
keys := make([]rollupKey, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sortRollupKeys(keys)
|
||||
|
||||
tags := cloneStringMap(query.Tags)
|
||||
out := make([]AggregatePoint, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
b := groups[key]
|
||||
out := make([]AggregatePoint, 0, len(bucketStarts))
|
||||
for _, start := range bucketStarts {
|
||||
b := groups[start]
|
||||
if b == nil {
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: query.MetricName,
|
||||
EntityID: query.EntityID,
|
||||
Bucket: time.Unix(0, start).UTC(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
v, ok := b.value(query.Aggregation)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
|
||||
@@ -137,10 +120,9 @@ func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query Aggreg
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: query.MetricName,
|
||||
EntityID: query.EntityID,
|
||||
Bucket: time.Unix(0, key.bucket).UTC(),
|
||||
Bucket: time.Unix(0, start).UTC(),
|
||||
Value: v,
|
||||
Count: int(b.count),
|
||||
Tags: cloneStringMap(tags),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -232,20 +214,17 @@ func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID
|
||||
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶,按桶合并摘要
|
||||
// (包括 t-digest)。groups 可为 nil,此时会分配新 map;传入已有 map 可让调用方
|
||||
// 把 rollup 和 raw 的贡献累加到同一批输出桶中。
|
||||
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64, preserveSeries bool) map[rollupKey]*rollupBucket {
|
||||
func foldRollupRows(groups map[int64]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64) map[int64]*rollupBucket {
|
||||
if groups == nil {
|
||||
groups = make(map[rollupKey]*rollupBucket)
|
||||
groups = make(map[int64]*rollupBucket)
|
||||
}
|
||||
size := interval.Nanoseconds()
|
||||
for _, r := range rows {
|
||||
bkt := floorDivNano(r.bucket, size)
|
||||
key := foldedRollupKey(r.entityID, r.bucketData.tagsHash, bkt, preserveSeries)
|
||||
b := groups[key]
|
||||
b := groups[bkt]
|
||||
if b == nil {
|
||||
b = newRollupBucket(comp)
|
||||
b.tagsHash = r.bucketData.tagsHash
|
||||
b.tagsJSON = r.bucketData.tagsJSON
|
||||
groups[key] = b
|
||||
groups[bkt] = b
|
||||
}
|
||||
b.mergeStored(r.bucketData)
|
||||
}
|
||||
@@ -261,57 +240,22 @@ func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, int
|
||||
// foldRawPoints 将原始点折叠进 interval 宽的输出桶,把每个观测值加入对应桶的
|
||||
// 累加器。它与 foldRollupRows 共用桶 map,因此混合查询可以把旧 rollup 半边和
|
||||
// 近期 raw 半边合并到同一批输出桶中并一起聚合(跨边界的 count/avg/百分位正确)。
|
||||
func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval time.Duration, comp float64, preserveSeries bool) (map[rollupKey]*rollupBucket, error) {
|
||||
func foldRawPoints(groups map[int64]*rollupBucket, points []Point, interval time.Duration, comp float64) map[int64]*rollupBucket {
|
||||
if groups == nil {
|
||||
groups = make(map[rollupKey]*rollupBucket)
|
||||
groups = make(map[int64]*rollupBucket)
|
||||
}
|
||||
size := interval.Nanoseconds()
|
||||
for _, p := range points {
|
||||
p = p.normalized()
|
||||
tagsHash, tagsJSON, err := tagsFingerprint(p.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := p.Timestamp.UTC().UnixNano()
|
||||
bkt := floorDivNano(ts, size)
|
||||
key := foldedRollupKey(p.EntityID, tagsHash, bkt, preserveSeries)
|
||||
b := groups[key]
|
||||
b := groups[bkt]
|
||||
if b == nil {
|
||||
b = newRollupBucket(comp)
|
||||
b.tagsHash = tagsHash
|
||||
b.tagsJSON = tagsJSON
|
||||
groups[key] = b
|
||||
groups[bkt] = b
|
||||
}
|
||||
b.addPoint(p.Value, ts)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func foldedRollupKey(entityID, tagsHash string, bucket int64, preserveSeries bool) rollupKey {
|
||||
if !preserveSeries {
|
||||
return rollupKey{bucket: bucket}
|
||||
}
|
||||
return rollupKey{entityID: entityID, tagsHash: tagsHash, bucket: bucket}
|
||||
}
|
||||
|
||||
func sortRollupKeys(keys []rollupKey) {
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].bucket != keys[j].bucket {
|
||||
return keys[i].bucket < keys[j].bucket
|
||||
}
|
||||
if keys[i].entityID != keys[j].entityID {
|
||||
return keys[i].entityID < keys[j].entityID
|
||||
}
|
||||
return keys[i].tagsHash < keys[j].tagsHash
|
||||
})
|
||||
}
|
||||
|
||||
func rollupTagsFromJSON(tagsJSON string) (map[string]string, error) {
|
||||
tags, err := decodeMapString(tagsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneStringMap(tags), nil
|
||||
return groups
|
||||
}
|
||||
|
||||
// scanStoredRollups reconstructs storedRollup rows (including tag identity and
|
||||
@@ -388,68 +332,6 @@ func rawTagsToJSON(v any) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// CompatibleSeriesInterval raises an output interval when the requested window
|
||||
// is only covered by a coarser rollup tier. If the requested start predates all
|
||||
// retained tiers, it uses the longest-retained tier so the available portion of
|
||||
// the window can still be returned.
|
||||
func (s *Store) CompatibleSeriesInterval(start, now time.Time, interval time.Duration) time.Duration {
|
||||
policy := s.cfg.RollupPolicy
|
||||
if interval <= 0 || !policy.Enabled() {
|
||||
return interval
|
||||
}
|
||||
|
||||
start = start.UTC()
|
||||
now = now.UTC()
|
||||
rawCutoff := policy.rawCutoff(now)
|
||||
if rawCutoff.IsZero() || !start.Before(rawCutoff) {
|
||||
return interval
|
||||
}
|
||||
|
||||
var fallback *RollupTier
|
||||
for i := range policy.Tiers {
|
||||
tier := &policy.Tiers[i]
|
||||
fallback = tier
|
||||
if now.Add(-tier.Retention).After(start) {
|
||||
continue
|
||||
}
|
||||
if interval < tier.Interval {
|
||||
return tier.Interval
|
||||
}
|
||||
if remainder := interval % tier.Interval; remainder != 0 {
|
||||
return interval + tier.Interval - remainder
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
if fallback != nil {
|
||||
if interval < fallback.Interval {
|
||||
return fallback.Interval
|
||||
}
|
||||
if remainder := interval % fallback.Interval; remainder != 0 {
|
||||
return interval + fallback.Interval - remainder
|
||||
}
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
// bestRollupTier returns the finest compatible tier that covers start. When
|
||||
// start is older than every retained tier, it returns the longest-retained
|
||||
// compatible tier so callers still receive the retained intersection.
|
||||
func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time.Time) *RollupTier {
|
||||
var fallback *RollupTier
|
||||
for i := range policy.Tiers {
|
||||
tier := &policy.Tiers[i]
|
||||
if interval < tier.Interval || interval%tier.Interval != 0 {
|
||||
continue
|
||||
}
|
||||
fallback = tier
|
||||
if !now.Add(-tier.Retention).After(start) {
|
||||
return tier
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Series answers an AggregateQuery by transparently choosing the best data
|
||||
// source for the requested window, given `now`:
|
||||
//
|
||||
@@ -461,9 +343,8 @@ func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time
|
||||
// - If the query spans the raw retention boundary, it uses a hybrid approach:
|
||||
// read rollups for the old part and raw for the recent part, then merge
|
||||
// the results to avoid losing uncompacted recent data.
|
||||
// - If the start predates every retained tier, it reads the longest-retained
|
||||
// compatible tier and returns the available intersection of the window.
|
||||
// - It falls back to raw only when no rollup tier is interval-compatible.
|
||||
// - If no tier qualifies, it falls back to raw (which may be incomplete for
|
||||
// data already aged out) so the call still returns its best answer.
|
||||
//
|
||||
// query.Tags is honored on both branches: the raw path already filters by tag,
|
||||
// and the rollup path filters by the stored tag set, so a tag filter selects the
|
||||
@@ -479,9 +360,8 @@ func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time
|
||||
// query.Interval,且 (b) 保留时间能覆盖到窗口起点,然后从该层服务查询。
|
||||
// - 如果查询跨越原始数据保留期边界,它使用混合方式:读取旧部分的 rollup
|
||||
// 和最近部分的原始点,然后合并结果,避免丢掉未 compact 的最近数据。
|
||||
// - 如果起点早于所有层级的保留窗口,它会读取保留时间最长的兼容层级,返回请求
|
||||
// 窗口与实际保留数据的交集。
|
||||
// - 只有没有任何层级与输出间隔兼容时才回退到原始点。
|
||||
// - 如果没有层级符合条件,它会回退到原始点(对于已经过期的数据可能不完整),
|
||||
// 让调用仍返回当前能给出的最佳答案。
|
||||
//
|
||||
// query.Tags 在两条分支上都会被遵守:原始路径已按标签过滤,rollup 路径会按
|
||||
// 存储的标签集合过滤,因此无论由哪个数据源回答,标签过滤都会选择相同序列。
|
||||
@@ -502,10 +382,6 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
|
||||
now = now.UTC()
|
||||
|
||||
rawCutoff := policy.rawCutoff(now)
|
||||
watermark, hasWatermark, err := s.compactionWatermark(ctx, q.MetricName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Whole window inside raw retention (or raw kept forever) -> raw.
|
||||
if rawCutoff.IsZero() || !q.Start.Before(rawCutoff) {
|
||||
@@ -517,25 +393,29 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
|
||||
return s.Aggregate(ctx, query)
|
||||
}
|
||||
|
||||
servicingTier := bestRollupTier(policy, query.Interval, q.Start, now)
|
||||
if q.Start.Before(rawCutoff) && !q.End.Before(rawCutoff) && servicingTier != nil {
|
||||
if hasWatermark {
|
||||
boundary := rawCutoff
|
||||
if watermark.Before(boundary) {
|
||||
boundary = watermark
|
||||
if q.Start.Before(rawCutoff) && !q.End.Before(rawCutoff) {
|
||||
for _, tier := range policy.Tiers { // finest-first
|
||||
if query.Interval < tier.Interval || query.Interval%tier.Interval != 0 {
|
||||
continue
|
||||
}
|
||||
if !q.Start.Before(boundary) {
|
||||
return s.Aggregate(ctx, query)
|
||||
if now.Add(-tier.Retention).After(q.Start) {
|
||||
continue
|
||||
}
|
||||
return s.seriesHybrid(ctx, query, boundary, servicingTier)
|
||||
return s.seriesHybrid(ctx, query, rawCutoff, &tier)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing stores may have rollups but no watermark yet. Read all raw
|
||||
// data from the requested start and use the dynamic cutoff only as the
|
||||
// rollup scan's upper bound. Compaction's raw/rollup invariant makes
|
||||
// these two ranges disjoint; the next successful compact persists the
|
||||
// precise boundary for subsequent queries.
|
||||
return s.seriesHybridWithRollupEnd(ctx, query, q.Start, rawCutoff, servicingTier)
|
||||
// Find the finest tier that can serve the entire window from the start.
|
||||
var servicingTier *RollupTier
|
||||
for _, tier := range policy.Tiers { // finest-first
|
||||
if query.Interval < tier.Interval || query.Interval%tier.Interval != 0 {
|
||||
continue
|
||||
}
|
||||
if now.Add(-tier.Retention).After(q.Start) {
|
||||
continue // tier doesn't reach back to the window start
|
||||
}
|
||||
servicingTier = &tier
|
||||
break
|
||||
}
|
||||
|
||||
if servicingTier == nil {
|
||||
@@ -558,10 +438,9 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
|
||||
// into one rollupBucket per output bucket fixes that: count, sum/avg, min/max and
|
||||
// percentiles are computed over the union of the two halves.
|
||||
//
|
||||
// The rollup bucket containing the raw cutoff may be partial: compaction only
|
||||
// folds points older than the cutoff into it. Include that bucket, then add raw
|
||||
// points at or after the cutoff, so the two halves neither overlap nor leave a
|
||||
// gap when the selected rollup resolution is coarser than raw retention.
|
||||
// To avoid double counting or gaps, the caller passes a boundary already aligned
|
||||
// to a rollup-resolution bucket. The rollup half supplies whole buckets strictly
|
||||
// before the boundary and the raw half supplies points at or after it.
|
||||
//
|
||||
// seriesHybrid 回答跨越原始保留期边界的查询:读取旧部分的 rollup 和近期部分的
|
||||
// 原始点,并在归约前把两者折叠进同一批 query.Interval 宽的输出桶。
|
||||
@@ -572,13 +451,9 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
|
||||
// 结果却只剩 raw 样本,count/avg 错误)。把两半折叠进同一个 rollupBucket 即可修复:
|
||||
// count、sum/avg、min/max 和百分位都在两半的并集上计算。
|
||||
//
|
||||
// 包含 raw 截止点的 rollup 桶可能尚未完整;它只包含截止点之前已压缩的数据。读取
|
||||
// 该桶,再合入截止点及之后的 raw 点,可以在粗粒度层级上避免重叠和缺口。
|
||||
// 为避免重复计数或留空,调用方传入的边界已经对齐到 rollup 分辨率桶。rollup 半边
|
||||
// 提供严格早于该边界的完整桶,raw 半边提供该边界及之后的点。
|
||||
func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary time.Time, tier *RollupTier) ([]AggregatePoint, error) {
|
||||
return s.seriesHybridWithRollupEnd(ctx, query, boundary, boundary, tier)
|
||||
}
|
||||
|
||||
func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQuery, boundary, rollupEnd time.Time, tier *RollupTier) ([]AggregatePoint, error) {
|
||||
q := query.Query.normalized()
|
||||
comp := s.cfg.RollupPolicy.compression()
|
||||
resNano := tier.Interval.Nanoseconds()
|
||||
@@ -586,17 +461,22 @@ func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQu
|
||||
endNano := q.End.UnixNano()
|
||||
splitAt := boundary.UTC().UnixNano()
|
||||
|
||||
groups := make(map[rollupKey]*rollupBucket)
|
||||
groups := make(map[int64]*rollupBucket)
|
||||
|
||||
// Old half: include the rollup bucket containing splitAt. That bucket only
|
||||
// contains compacted points before splitAt; the raw half starts at splitAt.
|
||||
upperBucket := floorDivNano(rollupEnd.UTC().UnixNano(), resNano)
|
||||
// Old half: rollup buckets fully contained in [Start, min(splitAt, End]].
|
||||
// "Fully before splitAt" is bucket <= splitAt-resNano (splitAt is aligned);
|
||||
// also clamp to the query's own inclusive end so a bucket past End is never
|
||||
// pulled in when splitAt overshoots a short window.
|
||||
upperBucket := splitAt - resNano
|
||||
if clamp := endNano - resNano + 1; clamp < upperBucket {
|
||||
upperBucket = clamp
|
||||
}
|
||||
if upperBucket >= startNano {
|
||||
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries)
|
||||
foldRollupRows(groups, rows, query.Interval, comp)
|
||||
}
|
||||
|
||||
// Recent half: raw points in [splitAt, End]. Reuse the raw Query path so the
|
||||
@@ -612,9 +492,7 @@ func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
foldRawPoints(groups, points, query.Interval, comp)
|
||||
}
|
||||
|
||||
out, err := rollupGroupsToPoints(groups, query)
|
||||
|
||||
+166
-181
@@ -3,30 +3,32 @@ package metric
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compact runs the store's RollupPolicy over every metric: it advances the
|
||||
// rollup tiers to `now` and enforces every retention window (raw points and each
|
||||
// tier). With finite raw retention, only newly eligible and late raw points are
|
||||
// propagated. It is safe to call repeatedly and is idempotent for unchanged
|
||||
// windows.
|
||||
// Compact runs the store's RollupPolicy over every metric: it rebuilds the
|
||||
// rollup tiers up to `now` and enforces every retention window (raw points and
|
||||
// each tier). It is the explicit maintenance entry point — call it from a cron
|
||||
// job or scheduler. It is safe to call repeatedly and is idempotent for
|
||||
// unchanged windows.
|
||||
//
|
||||
// Returns the number of rollup buckets written across all tiers and metrics.
|
||||
//
|
||||
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会把 rollup 层推进到
|
||||
// `now`,并执行每个保留窗口(原始点和各层级)。原始点保留期有限时,只会级联
|
||||
// 本轮新到期及迟到的原始点。它可以重复安全调用,未变化窗口保持幂等。
|
||||
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会重建截至 `now` 的
|
||||
// rollup 层,并执行每个保留窗口(原始点和各层级)。这是显式维护入口,
|
||||
// 应从 cron 任务或调度器调用。它可以重复安全调用,未变化窗口保持幂等。
|
||||
//
|
||||
// 返回所有指标、所有层级中写入的 rollup 桶数量。
|
||||
func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !s.cfg.RollupPolicy.Enabled() {
|
||||
return 0, nil
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -42,10 +44,10 @@ func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// CompactMetric compacts a single metric. With finite raw retention it consumes
|
||||
// newly eligible raw points as a delta and propagates that delta through every
|
||||
// tier. When raw is retained forever it falls back to rebuilding tiers. It then
|
||||
// deletes data that has aged out of each retention window.
|
||||
// CompactMetric compacts a single metric. It builds the finest tier from raw
|
||||
// points, then each coarser tier from the tier below it, upserts the resulting
|
||||
// buckets, and finally deletes data that has aged out of each retention window
|
||||
// (raw points first, then each tier).
|
||||
//
|
||||
// The entire compaction is performed within a SERIALIZABLE transaction to
|
||||
// guarantee that the raw scan and the raw deletion observe the same snapshot.
|
||||
@@ -63,9 +65,9 @@ func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
// combination (e.g. a GPU device_index) is summarized into its own series and
|
||||
// can be queried independently after the raw points are gone.
|
||||
//
|
||||
// CompactMetric 会压缩单个指标。原始点保留期有限时,它把本轮新到期的原始点
|
||||
// 作为增量逐层传播;原始点永久保留时则回退到全量重建。最后删除各保留窗口中
|
||||
// 已过期的数据。
|
||||
// CompactMetric 会压缩单个指标。它先由原始点构建最细层,再由下一层之下的
|
||||
// 层级逐层合成更粗层,upsert 生成的桶,最后删除每个保留窗口中过期的数据
|
||||
// (先删除原始点,再删除各层级)。
|
||||
//
|
||||
// 整个 compaction 在一个 SERIALIZABLE 事务内执行,以保证 raw 扫描和 raw 删除
|
||||
// 看到同一个快照。否则在 PostgreSQL/MySQL 的默认隔离级别下,扫描之后、删除
|
||||
@@ -78,26 +80,10 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.retentionMu.RLock()
|
||||
defer s.retentionMu.RUnlock()
|
||||
policy := s.cfg.RollupPolicy
|
||||
def, err := s.GetMetric(ctx, metricName)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if def.RetentionDays == 0 {
|
||||
_, err := s.DeleteSeries(ctx, Query{MetricName: metricName})
|
||||
return 0, err
|
||||
}
|
||||
if !policy.Enabled() {
|
||||
return 0, nil
|
||||
}
|
||||
effectivePolicy := policy.withMetricRetention(time.Duration(def.RetentionDays) * 24 * time.Hour)
|
||||
obsoleteIntervals := rollupIntervalsOutsidePolicy(policy.Tiers, effectivePolicy.Tiers)
|
||||
policy = effectivePolicy
|
||||
now = now.UTC()
|
||||
|
||||
// Retry the whole compaction on a transient serialization/deadlock failure.
|
||||
@@ -107,7 +93,7 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
|
||||
const maxAttempts = 5
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
written, err := s.compactMetricOnce(ctx, metricName, now, policy, obsoleteIntervals)
|
||||
written, err := s.compactMetricOnce(ctx, metricName, now)
|
||||
if err == nil {
|
||||
return written, nil
|
||||
}
|
||||
@@ -122,7 +108,7 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
|
||||
// compactMetricOnce runs a single compaction attempt inside one transaction.
|
||||
//
|
||||
// compactMetricOnce 在单个事务内执行一次 compaction 尝试。
|
||||
func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, obsoleteIntervals []time.Duration) (int, error) {
|
||||
func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now time.Time) (int, error) {
|
||||
// Use a transaction to ensure consistency between raw scan, rollup write, and
|
||||
// raw deletion. The isolation level is backend-specific (SERIALIZABLE on
|
||||
// PostgreSQL/MySQL, default on SQLite) so late-arriving points cannot be
|
||||
@@ -133,16 +119,10 @@ func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now ti
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := s.deleteRollupsForIntervalsTx(ctx, metricName, obsoleteIntervals, tx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := s.compactMetricWithinTx(ctx, metricName, now, policy, tx)
|
||||
written, err := s.compactMetricWithinTx(ctx, metricName, now, tx)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if err := s.persistCompactionWatermarkTx(ctx, metricName, policy.rawCutoff(now), tx); err != nil {
|
||||
return written, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return written, err
|
||||
@@ -150,20 +130,6 @@ func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now ti
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func rollupIntervalsOutsidePolicy(configured, effective []RollupTier) []time.Duration {
|
||||
active := make(map[time.Duration]struct{}, len(effective))
|
||||
for _, tier := range effective {
|
||||
active[tier.Interval] = struct{}{}
|
||||
}
|
||||
obsolete := make([]time.Duration, 0, len(configured))
|
||||
for _, tier := range configured {
|
||||
if _, ok := active[tier.Interval]; !ok {
|
||||
obsolete = append(obsolete, tier.Interval)
|
||||
}
|
||||
}
|
||||
return obsolete
|
||||
}
|
||||
|
||||
// isRetryableSerializationError reports whether err is a transient
|
||||
// serialization failure or deadlock that should be retried on a fresh
|
||||
// transaction. It matches on portable SQLSTATE codes and driver error text so
|
||||
@@ -206,49 +172,11 @@ func isRetryableSerializationError(err error) bool {
|
||||
// compactMetricWithinTx is the actual compaction logic, meant to be called
|
||||
// within a transaction. The tx parameter is passed explicitly to all operations
|
||||
// that need transactional consistency.
|
||||
func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
|
||||
func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, now time.Time, tx *sql.Tx) (int, error) {
|
||||
policy := s.cfg.RollupPolicy
|
||||
if !policy.Enabled() {
|
||||
return 0, nil
|
||||
}
|
||||
if policy.RawRetention > 0 {
|
||||
return s.compactMetricIncrementalWithinTx(ctx, metricName, now, policy, tx)
|
||||
}
|
||||
return s.compactMetricFullWithinTx(ctx, metricName, now, policy, tx)
|
||||
}
|
||||
|
||||
// compactMetricIncrementalWithinTx rolls only raw points crossing the hot-data
|
||||
// cutoff and propagates that delta through every coarser tier. Because those raw
|
||||
// points are deleted in the same transaction, each sample is merged exactly
|
||||
// once, including late samples for buckets that already exist.
|
||||
func (s *Store) compactMetricIncrementalWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
|
||||
comp := policy.compression()
|
||||
rawCutoff := policy.rawCutoff(now)
|
||||
delta, err := s.buildFinestTierBefore(ctx, tx, metricName, policy.Tiers[0].Interval, comp, rawCutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
written := 0
|
||||
for tierIdx, tier := range policy.Tiers {
|
||||
if tierIdx > 0 {
|
||||
delta = buildCoarserBucketsFromDelta(delta, tier.Interval, comp)
|
||||
}
|
||||
n, err := s.mergeRollupBucketsTx(ctx, metricName, tier.Interval, delta, tx)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
if err := s.enforceRetentionWithinTx(ctx, metricName, now, policy, tx); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// compactMetricFullWithinTx retains the rebuild behavior required when raw
|
||||
// points are kept forever and therefore cannot act as a consumed delta queue.
|
||||
func (s *Store) compactMetricFullWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
|
||||
comp := policy.compression()
|
||||
|
||||
written := 0
|
||||
@@ -306,52 +234,24 @@ func (s *Store) compactMetricFullWithinTx(ctx context.Context, metricName string
|
||||
prevDelta = currentDelta
|
||||
}
|
||||
|
||||
if err := s.enforceRetentionWithinTx(ctx, metricName, now, policy, tx); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// enforceRetentionWithinTx trims raw and rollup tiers after coarser data has
|
||||
// been materialized. Cutoffs retain a bucket that straddles the boundary.
|
||||
func (s *Store) enforceRetentionWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) error {
|
||||
// Enforce retention windows after all tiers are materialized, so a coarser
|
||||
// tier is always built before the finer source it depends on is trimmed.
|
||||
if policy.RawRetention > 0 {
|
||||
if _, err := s.DeleteBeforeTx(ctx, metricName, policy.rawCutoff(now), tx); err != nil {
|
||||
return err
|
||||
cutoff := policy.rawCutoff(now)
|
||||
if _, err := s.DeleteBeforeTx(ctx, metricName, cutoff, tx); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
for i, tier := range policy.Tiers {
|
||||
alignment := tier.Interval
|
||||
cutoff := now.Add(-tier.Retention)
|
||||
if i+1 < len(policy.Tiers) {
|
||||
alignment = policy.Tiers[i+1].Interval
|
||||
cutoff = alignRollupRetentionCutoff(cutoff, policy.Tiers[i+1].Interval)
|
||||
}
|
||||
cutoff := alignRollupRetentionCutoff(now.Add(-tier.Retention), alignment)
|
||||
if err := s.deleteRollupsBeforeTx(ctx, metricName, tier.Interval, cutoff, tx); err != nil {
|
||||
return err
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRollupsForIntervalsTx removes rows from resolutions that are no longer
|
||||
// part of a metric's effective retention policy.
|
||||
func (s *Store) deleteRollupsForIntervalsTx(ctx context.Context, metricName string, intervals []time.Duration, tx *sql.Tx) error {
|
||||
if len(intervals) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := make([]any, 1, len(intervals)+1)
|
||||
args[0] = metricName
|
||||
placeholders := make([]string, len(intervals))
|
||||
for i, interval := range intervals {
|
||||
placeholders[i] = s.dialect.placeholder(i + 2)
|
||||
args = append(args, interval.Nanoseconds())
|
||||
}
|
||||
sqlText := fmt.Sprintf(
|
||||
`DELETE FROM %s WHERE metric_name = %s AND resolution_nano IN (%s)`,
|
||||
s.tables.rollups, s.dialect.placeholder(1), strings.Join(placeholders, ", "),
|
||||
)
|
||||
_, err := tx.ExecContext(ctx, sqlText, args...)
|
||||
return err
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func alignRollupRetentionCutoff(cutoff time.Time, nextInterval time.Duration) time.Time {
|
||||
@@ -388,25 +288,13 @@ type rollupKey struct {
|
||||
// buildFinestTier 扫描某指标的原始点,并按给定 interval 分桶,key 为
|
||||
// (实体、标签集合、桶起点)。每个点的标签 map 决定它属于哪条序列。
|
||||
func (s *Store) buildFinestTier(ctx context.Context, q querier, metricName string, interval time.Duration, comp float64) (map[rollupKey]*rollupBucket, error) {
|
||||
return s.buildFinestTierBefore(ctx, q, metricName, interval, comp, time.Time{})
|
||||
}
|
||||
|
||||
// buildFinestTierBefore aggregates only raw points older than before. A zero
|
||||
// cutoff preserves the full-rebuild behavior used when raw is retained forever.
|
||||
func (s *Store) buildFinestTierBefore(ctx context.Context, q querier, metricName string, interval time.Duration, comp float64, before time.Time) (map[rollupKey]*rollupBucket, error) {
|
||||
size := interval.Nanoseconds()
|
||||
out := make(map[rollupKey]*rollupBucket)
|
||||
args := []any{metricName}
|
||||
where := "metric_name = " + s.dialect.placeholder(1)
|
||||
if !before.IsZero() {
|
||||
args = append(args, before.UTC().UnixNano())
|
||||
where += " AND ts_nano < " + s.dialect.placeholder(2)
|
||||
}
|
||||
sqlText := fmt.Sprintf(
|
||||
`SELECT entity_id, ts_nano, value, tags FROM %s WHERE %s ORDER BY ts_nano ASC`,
|
||||
s.tables.points, where,
|
||||
`SELECT entity_id, ts_nano, value, tags FROM %s WHERE metric_name = %s ORDER BY ts_nano ASC`,
|
||||
s.tables.points, s.dialect.placeholder(1),
|
||||
)
|
||||
rows, err := q.QueryContext(ctx, sqlText, args...)
|
||||
rows, err := q.QueryContext(ctx, sqlText, metricName)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -533,18 +421,27 @@ func (s *Store) scanRollupRows(ctx context.Context, q querier, metricName string
|
||||
return scanStoredRollups(rows)
|
||||
}
|
||||
|
||||
// mergeRollupBucketsTx merges a consumed raw-data delta into the affected
|
||||
// rollup buckets. Existing buckets are read by their indexed primary key, so a
|
||||
// compact run performs work proportional to new/late data rather than to the
|
||||
// complete retained history.
|
||||
func (s *Store) mergeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
|
||||
// writeRollupBuckets upserts a set of computed buckets for one resolution.
|
||||
//
|
||||
// writeRollupBuckets 将某个分辨率下计算出的 rollup 桶批量 upsert 到数据库。
|
||||
func (s *Store) writeRollupBuckets(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket) (int, error) {
|
||||
return s.writeRollupBucketsWithMergePoint(ctx, metricName, interval, buckets, time.Time{})
|
||||
}
|
||||
|
||||
// writeRollupBucketsTx upserts buckets within an existing transaction.
|
||||
func (s *Store) writeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
|
||||
return s.writeRollupBucketsWithMergePointTx(ctx, metricName, interval, buckets, time.Time{}, time.Time{}, nil, tx)
|
||||
}
|
||||
|
||||
// writeRollupBucketsWithMergePoint is the internal implementation that optionally
|
||||
// merges buckets older than a cutoff point instead of replacing them.
|
||||
func (s *Store) writeRollupBucketsWithMergePoint(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, mergeCutoff time.Time) (int, error) {
|
||||
if len(buckets) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
keys := make([]rollupKey, 0, len(buckets))
|
||||
for key := range buckets {
|
||||
keys = append(keys, key)
|
||||
for k := range buckets {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].entityID != keys[j].entityID {
|
||||
@@ -557,39 +454,70 @@ func (s *Store) mergeRollupBucketsTx(ctx context.Context, metricName string, int
|
||||
})
|
||||
|
||||
stmt := s.dialect.upsertRollupSQL(s.tables)
|
||||
resolution := interval.Nanoseconds()
|
||||
createdAt := time.Now().UTC().UnixNano()
|
||||
for _, key := range keys {
|
||||
bucket := buckets[key]
|
||||
existing, err := s.readRollupBucketTx(ctx, metricName, key.entityID, key.tagsHash, interval, key.bucket, tx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
resNano := interval.Nanoseconds()
|
||||
now := time.Now().UTC().UnixNano()
|
||||
mergeCutoffNano := mergeCutoff.UnixNano()
|
||||
|
||||
run := func(ex execer) error {
|
||||
for _, k := range keys {
|
||||
b := buckets[k]
|
||||
tagsJSON := b.tagsJSON
|
||||
if tagsJSON == "" {
|
||||
tagsJSON = "{}"
|
||||
}
|
||||
|
||||
// If mergeCutoff is set and this bucket is older than the cutoff,
|
||||
// merge with existing data instead of replacing it.
|
||||
if !mergeCutoff.IsZero() && k.bucket < mergeCutoffNano {
|
||||
// Read existing row if it exists
|
||||
existing, err := s.readRollupBucket(ctx, metricName, k.entityID, k.tagsHash, interval, k.bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
// Merge the new bucket into the existing one
|
||||
existing.mergeStored(b)
|
||||
b = existing
|
||||
}
|
||||
}
|
||||
|
||||
// Column order must match rollupColumns in dialect_rollup.go:
|
||||
// metric_name, entity_id, tags_hash, tags, resolution_nano, bucket_nano,
|
||||
// count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val,
|
||||
// last_ts, digest, created_at.
|
||||
_, err := ex.ExecContext(ctx, stmt,
|
||||
metricName, k.entityID, k.tagsHash, tagsJSON, resNano, k.bucket,
|
||||
b.count, b.sum, b.sumSq, b.min, b.max,
|
||||
b.firstVal, b.firstTS, b.lastVal, b.lastTS,
|
||||
b.digest.Encode(), now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if existing != nil {
|
||||
existing.mergeStored(bucket)
|
||||
bucket = existing
|
||||
}
|
||||
tagsJSON := bucket.tagsJSON
|
||||
if tagsJSON == "" {
|
||||
tagsJSON = "{}"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, stmt,
|
||||
metricName, key.entityID, key.tagsHash, tagsJSON, resolution, key.bucket,
|
||||
bucket.count, bucket.sum, bucket.sumSq, bucket.min, bucket.max,
|
||||
bucket.firstVal, bucket.firstTS, bucket.lastVal, bucket.lastTS,
|
||||
bucket.digest.Encode(), createdAt,
|
||||
); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(keys) == 1 {
|
||||
if err := run(s.db); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(keys), nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := run(tx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(keys), nil
|
||||
}
|
||||
|
||||
// writeRollupBucketsTx upserts buckets within an existing transaction.
|
||||
func (s *Store) writeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
|
||||
return s.writeRollupBucketsWithMergePointTx(ctx, metricName, interval, buckets, time.Time{}, time.Time{}, nil, tx)
|
||||
}
|
||||
|
||||
// writeRollupBucketsWithMergePointTx is the transactional version that executes
|
||||
// all operations within an existing transaction. deltaCutoff marks coarser
|
||||
// buckets whose source tier may already have aged out; a smaller recomputed
|
||||
@@ -674,6 +602,63 @@ func (s *Store) writeRollupBucketsWithMergePointTx(ctx context.Context, metricNa
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// readRollupBucket reads a single rollup bucket from storage, returning nil if
|
||||
// the bucket doesn't exist. Used to merge late-arriving data with existing rollups.
|
||||
func (s *Store) readRollupBucket(ctx context.Context, metricName, entityID, tagsHash string, interval time.Duration, bucketNano int64) (*rollupBucket, error) {
|
||||
sqlText := fmt.Sprintf(
|
||||
`SELECT count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, tags
|
||||
FROM %s WHERE metric_name = %s AND resolution_nano = %s AND entity_id = %s
|
||||
AND tags_hash = %s AND bucket_nano = %s`,
|
||||
s.tables.rollups,
|
||||
s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
|
||||
s.dialect.placeholder(4), s.dialect.placeholder(5),
|
||||
)
|
||||
row := s.reader().QueryRowContext(ctx, sqlText, metricName, interval.Nanoseconds(), entityID, tagsHash, bucketNano)
|
||||
|
||||
var count int64
|
||||
var sum, sumSq, minV, maxV, firstV, lastV float64
|
||||
var firstTS, lastTS int64
|
||||
var digestBlob []byte
|
||||
var rawTags any
|
||||
|
||||
err := row.Scan(&count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS, &digestBlob, &rawTags)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
td, err := DecodeTDigest(digestBlob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagsJSON, err := rawTagsToJSON(rawTags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &rollupBucket{
|
||||
count: count, sum: sum, sumSq: sumSq,
|
||||
min: minV, max: maxV,
|
||||
firstVal: firstV, firstTS: firstTS,
|
||||
lastVal: lastV, lastTS: lastTS,
|
||||
digest: td,
|
||||
tagsHash: tagsHash,
|
||||
tagsJSON: tagsJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deleteRollupsBefore deletes stored rollup rows older than a cutoff.
|
||||
func (s *Store) deleteRollupsBefore(ctx context.Context, metricName string, interval time.Duration, before time.Time) error {
|
||||
sqlText := fmt.Sprintf(
|
||||
`DELETE FROM %s WHERE metric_name = %s AND resolution_nano = %s AND bucket_nano < %s`,
|
||||
s.tables.rollups, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
|
||||
)
|
||||
_, err := s.db.ExecContext(ctx, sqlText, metricName, interval.Nanoseconds(), before.UTC().UnixNano())
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteRollupsBeforeTx deletes stored rollup rows within a transaction.
|
||||
func (s *Store) deleteRollupsBeforeTx(ctx context.Context, metricName string, interval time.Duration, before time.Time, tx *sql.Tx) error {
|
||||
sqlText := fmt.Sprintf(
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "util", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "util", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -63,9 +63,6 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
|
||||
if len(res0) != 1 || res0[0].Count != 10 || math.Abs(res0[0].Value-14.5) > 1e-9 {
|
||||
t.Fatalf("device 0 should be its own series avg=14.5 count=10, got %#v", res0)
|
||||
}
|
||||
if res0[0].Tags["device_index"] != "0" {
|
||||
t.Fatalf("device 0 rollup should keep tags, got %#v", res0[0])
|
||||
}
|
||||
// device 1: avg of 80..89 = 84.5
|
||||
res1, err := s.AggregateRollup(ctx, q("1"), time.Minute)
|
||||
if err != nil {
|
||||
@@ -74,33 +71,19 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
|
||||
if len(res1) != 1 || res1[0].Count != 10 || math.Abs(res1[0].Value-84.5) > 1e-9 {
|
||||
t.Fatalf("device 1 should be its own series avg=84.5 count=10, got %#v", res1)
|
||||
}
|
||||
if res1[0].Tags["device_index"] != "1" {
|
||||
t.Fatalf("device 1 rollup should keep tags, got %#v", res1[0])
|
||||
}
|
||||
|
||||
// No tag filter => both tag series are returned independently, matching
|
||||
// raw Aggregate and preserving the tag dimension for public query callers.
|
||||
// No tag filter => both series merge (count 20, avg of all 20 values).
|
||||
all, err := s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "util", EntityID: "host-1", Start: base, End: base.Add(10 * time.Minute)},
|
||||
Aggregation: AggAvg,
|
||||
Interval: time.Minute,
|
||||
PreserveSeries: true,
|
||||
Query: Query{MetricName: "util", EntityID: "host-1", Start: base, End: base.Add(10 * time.Minute)},
|
||||
Aggregation: AggAvg,
|
||||
Interval: time.Minute,
|
||||
}, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("rollup all: %v", err)
|
||||
}
|
||||
byDevice := map[string]AggregatePoint{}
|
||||
for _, point := range all {
|
||||
byDevice[point.Tags["device_index"]] = point
|
||||
}
|
||||
if len(byDevice) != 2 {
|
||||
t.Fatalf("no-filter should keep both tag series, got %#v", all)
|
||||
}
|
||||
if got := byDevice["0"]; got.Count != 10 || math.Abs(got.Value-14.5) > 1e-9 {
|
||||
t.Fatalf("unexpected no-filter device 0 rollup: %#v", got)
|
||||
}
|
||||
if got := byDevice["1"]; got.Count != 10 || math.Abs(got.Value-84.5) > 1e-9 {
|
||||
t.Fatalf("unexpected no-filter device 1 rollup: %#v", got)
|
||||
wantAvg := (145.0 + 845.0) / 20.0 // sum(10..19)=145, sum(80..89)=845
|
||||
if len(all) != 1 || all[0].Count != 20 || math.Abs(all[0].Value-wantAvg) > 1e-9 {
|
||||
t.Fatalf("no-filter should merge both tags count=20 avg=%.2f, got %#v", wantAvg, all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +102,7 @@ func TestRollupTagPercentileSurvivesRetention(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 365 * 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -184,7 +167,7 @@ func TestSeriesTagFilterRoutesPerTag(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -244,7 +227,7 @@ func TestDeleteSeriesRemovesTaggedTaskAcrossAgents(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "ping.latency_ms", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "ping.latency_ms", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -311,7 +294,7 @@ func TestDeleteMetricRemovesRollups(t *testing.T) {
|
||||
s := newRollupStore(t, RollupPolicy{
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
})
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "gone", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "gone", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
+10
-415
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -34,7 +33,7 @@ func newRollupStore(t *testing.T, policy RollupPolicy) *Store {
|
||||
func TestArbitraryPercentileOverRaw(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -114,7 +113,7 @@ func TestCompactBuildsFinestTier(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -190,7 +189,7 @@ func TestCompactCascadeFineToCoarse(t *testing.T) {
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "c", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "c", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -245,7 +244,7 @@ func TestCompactDoesNotOverwriteCoarseRollupWithPartialFineRows(t *testing.T) {
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "partial", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "partial", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -316,7 +315,7 @@ func TestCompactMergesLateFineDeltaLargerThanCoarseBucket(t *testing.T) {
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "latebig", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "latebig", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -362,7 +361,7 @@ func TestRetentionDropsRawButPercentileSurvives(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 365 * 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "old", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "old", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -417,7 +416,7 @@ func TestCompactMergesLateRawIntoExpiredRollup(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "late", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "late", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -458,84 +457,6 @@ func TestCompactMergesLateRawIntoExpiredRollup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleSeriesIntervalUsesTierCoveringWindow(t *testing.T) {
|
||||
now := time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC)
|
||||
s := &Store{cfg: Config{RollupPolicy: RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 60 * 24 * time.Hour},
|
||||
},
|
||||
}}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
interval time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "recent raw", start: now.Add(-5 * time.Minute), interval: 5 * time.Second, want: 5 * time.Second},
|
||||
{name: "one minute tier", start: now.Add(-24 * time.Hour), interval: 2 * time.Minute, want: 2 * time.Minute},
|
||||
{name: "five minute tier", start: now.Add(-7 * 24 * time.Hour), interval: 10 * time.Minute, want: 10 * time.Minute},
|
||||
{name: "five minute retention boundary", start: now.Add(-14 * 24 * time.Hour), interval: 30 * time.Minute, want: 30 * time.Minute},
|
||||
{name: "hour tier", start: now.Add(-15 * 24 * time.Hour), interval: 30 * time.Minute, want: time.Hour},
|
||||
{name: "round to hour multiple", start: now.Add(-15 * 24 * time.Hour), interval: 90 * time.Minute, want: 2 * time.Hour},
|
||||
{name: "before longest retention", start: now.Add(-61 * 24 * time.Hour), interval: 30 * time.Minute, want: time.Hour},
|
||||
{name: "before longest retention already compatible", start: now.Add(-61 * 24 * time.Hour), interval: 3 * time.Hour, want: 3 * time.Hour},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := s.CompatibleSeriesInterval(test.start, now, test.interval); got != test.want {
|
||||
t.Fatalf("CompatibleSeriesInterval() = %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesStartBeforeLongestRetentionReturnsAvailableRollup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 90 * 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "long-window", Type: TypeGauge, RetentionDays: 90}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
|
||||
pointTime := now.Add(-80 * 24 * time.Hour).Add(15 * time.Minute)
|
||||
if err := s.Write(ctx, Point{MetricName: "long-window", EntityID: "n1", Timestamp: pointTime, Value: 42}); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Series(ctx, AggregateQuery{
|
||||
Query: Query{
|
||||
MetricName: "long-window",
|
||||
EntityID: "n1",
|
||||
Start: now.Add(-91 * 24 * time.Hour),
|
||||
End: now,
|
||||
},
|
||||
Aggregation: AggAvg,
|
||||
Interval: 3 * time.Hour,
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("series: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != 1 || got[0].Value != 42 {
|
||||
t.Fatalf("expected retained intersection, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesRoutesByAge verifies Series auto-routes between raw and rollup data
|
||||
// based on the query window's age.
|
||||
//
|
||||
@@ -548,7 +469,7 @@ func TestSeriesRoutesByAge(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sr", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "sr", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -629,7 +550,7 @@ func TestSeriesAcrossRetentionIncludesUncompactedRecentRaw(t *testing.T) {
|
||||
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "hybrid", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "hybrid", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
@@ -705,117 +626,6 @@ func TestRollupPolicyValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollupPolicyWithMetricRetentionPrunesRedundantTiers(t *testing.T) {
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
|
||||
},
|
||||
Compression: 100,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
retention time.Duration
|
||||
want []RollupTier
|
||||
}{
|
||||
{
|
||||
name: "one day keeps finest tier",
|
||||
retention: 24 * time.Hour,
|
||||
want: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 24 * time.Hour},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "seven days keeps minute and five minute tiers",
|
||||
retention: 7 * 24 * time.Hour,
|
||||
want: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 7 * 24 * time.Hour},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thirty days keeps all tiers",
|
||||
retention: 30 * 24 * time.Hour,
|
||||
want: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := policy.withMetricRetention(test.retention)
|
||||
if !reflect.DeepEqual(got.Tiers, test.want) {
|
||||
t.Fatalf("tiers = %#v, want %#v", got.Tiers, test.want)
|
||||
}
|
||||
if got.Compression != 100 {
|
||||
t.Fatalf("compression = %v, want 100", got.Compression)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(policy.Tiers) != 3 || policy.Tiers[2].Retention != 14*24*time.Hour {
|
||||
t.Fatalf("withMetricRetention mutated source policy: %#v", policy.Tiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRemovesRollupsFromRedundantTiers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "daily", Type: TypeGauge, RetentionDays: 1}); err != nil {
|
||||
t.Fatalf("create metric: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
||||
bucketTime := now.Add(-2 * time.Hour)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin seed transaction: %v", err)
|
||||
}
|
||||
for _, interval := range []time.Duration{time.Minute, 5 * time.Minute, time.Hour} {
|
||||
bucket := newRollupBucket(policy.compression())
|
||||
bucket.addPoint(42, bucketTime.UnixNano())
|
||||
key := rollupKey{entityID: "node", bucket: bucketTime.UnixNano()}
|
||||
if _, err := s.writeRollupBucketsTx(ctx, "daily", interval, map[rollupKey]*rollupBucket{key: bucket}, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatalf("seed %s rollup: %v", interval, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit seed transaction: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CompactMetric(ctx, "daily", now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
interval time.Duration
|
||||
want int
|
||||
}{
|
||||
{interval: time.Minute, want: 1},
|
||||
{interval: 5 * time.Minute, want: 0},
|
||||
{interval: time.Hour, want: 0},
|
||||
} {
|
||||
rows, err := s.scanRollupRows(ctx, s.reader(), "daily", test.interval)
|
||||
if err != nil {
|
||||
t.Fatalf("scan %s rollups: %v", test.interval, err)
|
||||
}
|
||||
if len(rows) != test.want {
|
||||
t.Fatalf("%s rollup rows = %d, want %d", test.interval, len(rows), test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompactIdempotent verifies Compact is idempotent for unchanged windows.
|
||||
//
|
||||
// TestCompactIdempotent 验证 Compact 对未变化窗口保持幂等。
|
||||
@@ -823,7 +633,7 @@ func TestCompactIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "idem", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "idem", Type: TypeGauge}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
@@ -864,218 +674,3 @@ func TestCompactIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactWithRawRetentionOnlyWritesChangedBuckets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 2 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "incremental", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
|
||||
if err := s.Write(ctx, Point{MetricName: "incremental", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 1}); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
now := base.Add(10 * time.Minute)
|
||||
first, err := s.Compact(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("compact first: %v", err)
|
||||
}
|
||||
if first != 2 {
|
||||
t.Fatalf("first compact wrote %d buckets, want 2", first)
|
||||
}
|
||||
second, err := s.Compact(ctx, now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("compact unchanged: %v", err)
|
||||
}
|
||||
if second != 0 {
|
||||
t.Fatalf("unchanged compact rewrote %d buckets, want 0", second)
|
||||
}
|
||||
|
||||
if err := s.Write(ctx, Point{MetricName: "incremental", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 2}); err != nil {
|
||||
t.Fatalf("write late: %v", err)
|
||||
}
|
||||
late, err := s.Compact(ctx, now.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("compact late: %v", err)
|
||||
}
|
||||
if late != 2 {
|
||||
t.Fatalf("late compact wrote %d buckets, want 2", late)
|
||||
}
|
||||
got, err := s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "incremental", EntityID: "n1", Start: base, End: base.Add(5*time.Minute - time.Nanosecond)},
|
||||
Aggregation: AggSum,
|
||||
Interval: 5 * time.Minute,
|
||||
}, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("rollup: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != 2 || got[0].Value != 3 {
|
||||
t.Fatalf("late delta was not merged exactly once: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactHonorsMetricRetentionForCoarsestTier(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 90 * 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "retained", Type: TypeGauge, RetentionDays: 60}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "longer-retained", Type: TypeGauge, RetentionDays: 120}); err != nil {
|
||||
t.Fatalf("create longer metric: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC)
|
||||
points := []Point{
|
||||
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-70 * 24 * time.Hour), Value: 1},
|
||||
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-60*24*time.Hour + 10*time.Minute), Value: 2},
|
||||
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-50 * 24 * time.Hour), Value: 3},
|
||||
{MetricName: "longer-retained", EntityID: "n1", Timestamp: now.Add(-100 * 24 * time.Hour), Value: 4},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "retained", EntityID: "n1", Start: now.Add(-80 * 24 * time.Hour), End: now},
|
||||
Aggregation: AggSum,
|
||||
Interval: time.Hour,
|
||||
}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("rollup: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 retained buckets, got %#v", got)
|
||||
}
|
||||
if got[0].Value != 2 || got[1].Value != 3 {
|
||||
t.Fatalf("unexpected retained values: %#v", got)
|
||||
}
|
||||
longer, err := s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "longer-retained", EntityID: "n1", Start: now.Add(-130 * 24 * time.Hour), End: now},
|
||||
Aggregation: AggSum,
|
||||
Interval: time.Hour,
|
||||
}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("longer rollup: %v", err)
|
||||
}
|
||||
if len(longer) != 1 || longer[0].Value != 4 {
|
||||
t.Fatalf("metric retention leaked across definitions: %#v", longer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroRetentionPurgesDataAndDisablesFurtherPersistence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := RollupPolicy{
|
||||
RawRetention: 15 * time.Minute,
|
||||
Tiers: []RollupTier{
|
||||
{Interval: time.Minute, Retention: 48 * time.Hour},
|
||||
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
|
||||
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
|
||||
},
|
||||
}
|
||||
s := newRollupStore(t, policy)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 0}); err != nil {
|
||||
t.Fatalf("create disabled metric: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
|
||||
def, err := s.GetMetric(ctx, "disabled")
|
||||
if err != nil || def.RetentionDays != 0 {
|
||||
t.Fatalf("CreateMetric did not persist zero retention: %#v, err=%v", def, err)
|
||||
}
|
||||
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 1}); err != nil {
|
||||
t.Fatalf("write disabled metric: %v", err)
|
||||
}
|
||||
raw, err := s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
|
||||
if err != nil || len(raw) != 0 {
|
||||
t.Fatalf("CreateMetric zero persisted data: %#v, err=%v", raw, err)
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("enable metric: %v", err)
|
||||
}
|
||||
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now.Add(-time.Hour), Value: 1}); err != nil {
|
||||
t.Fatalf("write initial point: %v", err)
|
||||
}
|
||||
if _, err := s.CompactMetric(ctx, "disabled", now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
rollups, err := s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now},
|
||||
Aggregation: AggSum,
|
||||
Interval: time.Minute,
|
||||
}, time.Minute)
|
||||
if err != nil || len(rollups) == 0 {
|
||||
t.Fatalf("expected persisted rollup before disable, got %#v, err=%v", rollups, err)
|
||||
}
|
||||
|
||||
if err := s.UpsertMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 0}); err != nil {
|
||||
t.Fatalf("upsert zero retention: %v", err)
|
||||
}
|
||||
def, err = s.GetMetric(ctx, "disabled")
|
||||
if err != nil || def.RetentionDays != 0 {
|
||||
t.Fatalf("UpsertMetric did not persist zero retention: %#v, err=%v", def, err)
|
||||
}
|
||||
raw, err = s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now})
|
||||
if err != nil || len(raw) != 0 {
|
||||
t.Fatalf("raw data remained after disable: %#v, err=%v", raw, err)
|
||||
}
|
||||
rollups, err = s.AggregateRollup(ctx, AggregateQuery{
|
||||
Query: Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now},
|
||||
Aggregation: AggSum,
|
||||
Interval: time.Minute,
|
||||
}, time.Minute)
|
||||
if err != nil || len(rollups) != 0 {
|
||||
t.Fatalf("rollup data remained after disable: %#v, err=%v", rollups, err)
|
||||
}
|
||||
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 2}); err != nil {
|
||||
t.Fatalf("write disabled metric: %v", err)
|
||||
}
|
||||
raw, err = s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
|
||||
if err != nil || len(raw) != 0 {
|
||||
t.Fatalf("disabled metric persisted a new point: %#v, err=%v", raw, err)
|
||||
}
|
||||
if written, err := s.CompactMetric(ctx, "disabled", now); err != nil || written != 0 {
|
||||
t.Fatalf("disabled metric compacted %d buckets, err=%v", written, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPurgesZeroRetentionWithoutRollups(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newMemStore(t)
|
||||
if err := s.CreateMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 30}); err != nil {
|
||||
t.Fatalf("create metric: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
|
||||
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 1}); err != nil {
|
||||
t.Fatalf("write point: %v", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
fmt.Sprintf("UPDATE %s SET retention_days = ? WHERE name = ?", s.tables.definitions),
|
||||
0, "disabled",
|
||||
); err != nil {
|
||||
t.Fatalf("seed disabled definition: %v", err)
|
||||
}
|
||||
if written, err := s.Compact(ctx, now); err != nil || written != 0 {
|
||||
t.Fatalf("compact = %d, err=%v", written, err)
|
||||
}
|
||||
points, err := s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
|
||||
if err != nil || len(points) != 0 {
|
||||
t.Fatalf("zero-retention data remained without rollups: %#v, err=%v", points, err)
|
||||
}
|
||||
}
|
||||
+16
-206
@@ -50,14 +50,6 @@ type Store struct {
|
||||
//
|
||||
// tables 保存当前 Store 的实际表名。
|
||||
tables tables
|
||||
// maintenanceMu serializes physical storage maintenance while allowing
|
||||
// concurrent size reads.
|
||||
//
|
||||
// maintenanceMu 串行化物理存储维护,同时允许并发读取存储大小。
|
||||
maintenanceMu sync.RWMutex
|
||||
// retentionMu serializes a retention change with writes and compaction so a
|
||||
// disabled metric cannot be repopulated by an in-flight operation.
|
||||
retentionMu sync.RWMutex
|
||||
// mu protects closed state.
|
||||
//
|
||||
// mu 保护 closed 状态。
|
||||
@@ -72,6 +64,9 @@ type Store struct {
|
||||
//
|
||||
// Open 根据配置打开 Store,初始化连接池,并在需要时执行自动迁移。
|
||||
func Open(ctx context.Context, cfg Config) (*Store, error) {
|
||||
if cfg.DefaultRetentionDays == 0 {
|
||||
cfg.DefaultRetentionDays = 90
|
||||
}
|
||||
if cfg.TablePrefix == "" {
|
||||
cfg.TablePrefix = "metric_"
|
||||
}
|
||||
@@ -96,7 +91,6 @@ func Open(ctx context.Context, cfg Config) (*Store, error) {
|
||||
definitions: tableName(cfg.TablePrefix, "definitions"),
|
||||
points: tableName(cfg.TablePrefix, "points"),
|
||||
rollups: tableName(cfg.TablePrefix, "rollups"),
|
||||
watermarks: tableName(cfg.TablePrefix, "compaction_watermarks"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -382,7 +376,7 @@ func (s *Store) CreateMetric(ctx context.Context, def Definition) error {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
def = def.withDefaults()
|
||||
def = def.withDefaults(s.cfg.DefaultRetentionDays)
|
||||
if err := def.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -429,16 +423,10 @@ func (s *Store) UpsertMetric(ctx context.Context, def Definition) error {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
def = def.withDefaults()
|
||||
def = def.withDefaults(s.cfg.DefaultRetentionDays)
|
||||
if err := def.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if def.RetentionDays == 0 {
|
||||
// Serialize the zero-retention transition with writes and compaction before
|
||||
// changing the definition or removing data left by an enabled definition.
|
||||
s.retentionMu.Lock()
|
||||
defer s.retentionMu.Unlock()
|
||||
}
|
||||
metadata, err := encodeMap(def.Metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -456,10 +444,6 @@ func (s *Store) UpsertMetric(ctx context.Context, def Definition) error {
|
||||
now,
|
||||
now,
|
||||
)
|
||||
if err != nil || def.RetentionDays != 0 {
|
||||
return err
|
||||
}
|
||||
_, err = s.DeleteSeries(ctx, Query{MetricName: def.Name})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -533,75 +517,12 @@ func (s *Store) DeleteMetric(ctx context.Context, name string) error {
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.points, s.dialect.placeholder(1)), name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)), name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, s.tables.definitions, s.dialect.placeholder(1)), name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SetMetricRetention updates one metric's retention policy. A value of zero
|
||||
// disables persistence for that metric and atomically removes its raw and
|
||||
// rollup data. Negative values are invalid.
|
||||
func (s *Store) SetMetricRetention(ctx context.Context, name string, retentionDays int) (Definition, error) {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Definition{}, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
|
||||
}
|
||||
if retentionDays < 0 {
|
||||
return Definition{}, fmt.Errorf("%w: retention days cannot be negative", ErrInvalidArgument)
|
||||
}
|
||||
|
||||
s.retentionMu.Lock()
|
||||
defer s.retentionMu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
updatedAt := time.Now().UTC().UnixNano()
|
||||
result, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET retention_days = %s, updated_at = %s WHERE name = %s`,
|
||||
s.tables.definitions, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3)),
|
||||
retentionDays, updatedAt, name,
|
||||
)
|
||||
if err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return Definition{}, fmt.Errorf("%w: metric %q", ErrNotFound, name)
|
||||
}
|
||||
if retentionDays == 0 {
|
||||
for _, table := range []string{s.tables.points, s.tables.rollups} {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
|
||||
); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)), name,
|
||||
); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
return s.GetMetric(ctx, name)
|
||||
}
|
||||
|
||||
// DeleteEntity deletes all raw and rollup data for one entity across every metric.
|
||||
//
|
||||
// DeleteEntity 删除某个实体在所有指标下的原始点和 rollup 数据。
|
||||
@@ -678,14 +599,6 @@ func (s *Store) DeleteSeries(ctx context.Context, filter Query) (int64, error) {
|
||||
}
|
||||
total += n
|
||||
}
|
||||
if strings.TrimSpace(filter.EntityID) == "" && len(filter.Tags) == 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)),
|
||||
filter.MetricName,
|
||||
); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return total, err
|
||||
}
|
||||
@@ -709,15 +622,6 @@ func (s *Store) WriteBatch(ctx context.Context, points []Point) error {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.retentionMu.RLock()
|
||||
defer s.retentionMu.RUnlock()
|
||||
points, err := s.filterDisabledMetricPoints(ctx, points)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
const batchSize = 1000
|
||||
// A single chunk is one statement; send it directly. Multiple chunks are
|
||||
// wrapped in one transaction so the batch is all-or-nothing rather than
|
||||
@@ -742,31 +646,6 @@ func (s *Store) WriteBatch(ctx context.Context, points []Point) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// filterDisabledMetricPoints preserves the historic behavior for unknown
|
||||
// metrics while dropping points whose known definition has zero retention.
|
||||
func (s *Store) filterDisabledMetricPoints(ctx context.Context, points []Point) ([]Point, error) {
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
disabled := make(map[string]struct{})
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays == 0 {
|
||||
disabled[def.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(disabled) == 0 {
|
||||
return points, nil
|
||||
}
|
||||
filtered := make([]Point, 0, len(points))
|
||||
for _, point := range points {
|
||||
if _, ok := disabled[point.MetricName]; !ok {
|
||||
filtered = append(filtered, point)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// execer is satisfied by both *sql.DB and *sql.Tx, letting writeBatch run either
|
||||
// standalone or inside the batch transaction.
|
||||
//
|
||||
@@ -878,63 +757,6 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Point, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EntityIDs returns distinct entity ids that have raw or rollup data matching a query.
|
||||
//
|
||||
// EntityIDs 返回在原始点或 rollup 中匹配查询条件的实体 ID。
|
||||
func (s *Store) EntityIDs(ctx context.Context, query Query) ([]string, error) {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := query.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = query.normalized()
|
||||
|
||||
pointsWhere, args := s.buildWhere(query)
|
||||
rollupArgsStart := len(args)
|
||||
rollupArgs := []any{query.MetricName, query.Start.UnixNano(), query.End.UnixNano()}
|
||||
rollupParts := []string{
|
||||
"metric_name = " + s.dialect.placeholder(rollupArgsStart+1),
|
||||
"bucket_nano >= " + s.dialect.placeholder(rollupArgsStart+2),
|
||||
"bucket_nano <= " + s.dialect.placeholder(rollupArgsStart+3),
|
||||
}
|
||||
if strings.TrimSpace(query.EntityID) != "" {
|
||||
rollupArgs = append(rollupArgs, query.EntityID)
|
||||
rollupParts = append(rollupParts, "entity_id = "+s.dialect.placeholder(rollupArgsStart+len(rollupArgs)))
|
||||
}
|
||||
for _, k := range sortedKeys(query.Tags) {
|
||||
rollupArgs = append(rollupArgs, query.Tags[k])
|
||||
rollupParts = append(rollupParts, s.dialect.jsonExtractEquals("tags", k, s.dialect.placeholder(rollupArgsStart+len(rollupArgs))))
|
||||
}
|
||||
args = append(args, rollupArgs...)
|
||||
|
||||
sqlText := fmt.Sprintf(`SELECT DISTINCT entity_id FROM (
|
||||
SELECT entity_id FROM %s WHERE %s
|
||||
UNION
|
||||
SELECT entity_id FROM %s WHERE %s
|
||||
) AS metric_entities ORDER BY entity_id ASC`,
|
||||
s.tables.points, pointsWhere,
|
||||
s.tables.rollups, strings.Join(rollupParts, " AND "),
|
||||
)
|
||||
rows, err := s.reader().QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var entityID string
|
||||
if err := rows.Scan(&entityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entityID != "" {
|
||||
out = append(out, entityID)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Latest loads the newest points for a metric and entity.
|
||||
//
|
||||
// Latest 查询某指标和实体的最新采样点。
|
||||
@@ -997,9 +819,9 @@ func (s *Store) Aggregate(ctx context.Context, query AggregateQuery) ([]Aggregat
|
||||
}
|
||||
// Push simple reductions (avg/min/max/sum/count) down to SQL via GROUP BY on
|
||||
// a time bucket so large ranges don't pull every raw point into memory.
|
||||
// Percentiles, first/last and rate need the ordered raw series, so those fall
|
||||
// back to the in-memory aggregator.
|
||||
if valueExpr, ok := sqlAggValueExpr(s.cfg.Driver, query.Aggregation); ok {
|
||||
// Percentiles, first/last, rate and empty-bucket filling need the ordered
|
||||
// raw series, so those fall back to the in-memory aggregator.
|
||||
if valueExpr, ok := sqlAggValueExpr(s.cfg.Driver, query.Aggregation); ok && !query.FillEmpty {
|
||||
return s.aggregateInSQL(ctx, query, valueExpr)
|
||||
}
|
||||
// In-memory fallback. Strip the embedded raw-point Limit/Offset so the full
|
||||
@@ -1059,7 +881,7 @@ func (s *Store) aggregateInSQL(ctx context.Context, query AggregateQuery, valueE
|
||||
// bucket column so writes stay cheap and the bucket size can vary per query.
|
||||
bucketExpr := fmt.Sprintf("(ts_nano - ((ts_nano %% %d) + %d) %% %d)", interval, interval, interval)
|
||||
sqlText := fmt.Sprintf(
|
||||
`SELECT %s AS bucket, metric_name, entity_id, tags_hash, tags, %s AS agg_value, COUNT(*) AS agg_count FROM %s WHERE %s GROUP BY bucket, metric_name, entity_id, tags_hash, tags ORDER BY bucket ASC, metric_name ASC, entity_id ASC, tags_hash ASC`,
|
||||
`SELECT %s AS bucket, %s AS agg_value, COUNT(*) AS agg_count FROM %s WHERE %s GROUP BY bucket ORDER BY bucket ASC`,
|
||||
bucketExpr, valueExpr, s.tables.points, where,
|
||||
)
|
||||
// Page over aggregate buckets (BucketLimit/BucketOffset), not raw points.
|
||||
@@ -1081,25 +903,17 @@ func (s *Store) aggregateInSQL(ctx context.Context, query AggregateQuery, valueE
|
||||
out := make([]AggregatePoint, 0)
|
||||
for rows.Next() {
|
||||
var bucket int64
|
||||
var metricName, entityID, tagsHash string
|
||||
var rawTags any
|
||||
var value float64
|
||||
var count int
|
||||
if err := rows.Scan(&bucket, &metricName, &entityID, &tagsHash, &rawTags, &value, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = tagsHash
|
||||
tags, err := decodeMap(rawTags)
|
||||
if err != nil {
|
||||
if err := rows.Scan(&bucket, &value, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AggregatePoint{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
MetricName: q.MetricName,
|
||||
EntityID: q.EntityID,
|
||||
Bucket: time.Unix(0, bucket).UTC(),
|
||||
Value: value,
|
||||
Count: count,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -1159,15 +973,11 @@ func (s *Store) CleanupExpired(ctx context.Context, now time.Time) (int64, error
|
||||
}
|
||||
var total int64
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays == 0 {
|
||||
deleted, err := s.DeleteSeries(ctx, Query{MetricName: def.Name})
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += deleted
|
||||
continue
|
||||
retentionDays := def.RetentionDays
|
||||
if retentionDays <= 0 {
|
||||
retentionDays = s.cfg.DefaultRetentionDays
|
||||
}
|
||||
deleted, err := s.DeleteBefore(ctx, def.Name, now.AddDate(0, 0, -def.RetentionDays))
|
||||
deleted, err := s.DeleteBefore(ctx, def.Name, now.AddDate(0, 0, -retentionDays))
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "cpu.usage", Type: TypeGauge, Unit: "%", RetentionDays: 30}); err != nil {
|
||||
if err := store.CreateMetric(ctx, Definition{Name: "cpu.usage", Type: TypeGauge, Unit: "%"}); err != nil {
|
||||
t.Fatalf("create metric: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+10
-15
@@ -143,10 +143,9 @@ type Definition struct {
|
||||
//
|
||||
// Unit 表示数值单位,例如 bytes 或 percent。
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// RetentionDays controls historical data retention for this metric. A value
|
||||
// of zero disables persistence and removes existing metric data.
|
||||
// RetentionDays controls raw-point retention for this metric.
|
||||
//
|
||||
// RetentionDays 控制该指标历史数据的保留天数;零表示禁用持久化并清除已有数据。
|
||||
// RetentionDays 控制该指标原始点的保留天数。
|
||||
RetentionDays int `json:"retention_days,omitempty"`
|
||||
// Metadata stores caller-defined metric metadata.
|
||||
//
|
||||
@@ -164,11 +163,14 @@ type Definition struct {
|
||||
|
||||
// withDefaults fills default values on a metric definition.
|
||||
//
|
||||
// withDefaults 为指标定义填充默认类型。
|
||||
func (d Definition) withDefaults() Definition {
|
||||
// withDefaults 为指标定义填充默认类型和默认保留天数。
|
||||
func (d Definition) withDefaults(defaultRetentionDays int) Definition {
|
||||
if d.Type == "" {
|
||||
d.Type = TypeGauge
|
||||
}
|
||||
if d.RetentionDays == 0 {
|
||||
d.RetentionDays = defaultRetentionDays
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -343,13 +345,10 @@ type AggregateQuery struct {
|
||||
//
|
||||
// Interval 是每个聚合桶的宽度。
|
||||
Interval time.Duration `json:"interval"`
|
||||
// PreserveSeries keeps entity/tag identities as separate aggregate series on
|
||||
// rollup-backed reads. The default preserves the historical rollup behavior
|
||||
// of merging all matched series into each output bucket.
|
||||
// FillEmpty emits zero-count buckets for empty intervals.
|
||||
//
|
||||
// PreserveSeries 在基于 rollup 的读取中保留 entity/tag 维度为独立聚合序列。
|
||||
// 默认值保留历史 rollup 行为:把匹配到的序列合并进同一个输出桶。
|
||||
PreserveSeries bool `json:"preserve_series,omitempty"`
|
||||
// FillEmpty 会为空时间段输出零计数桶。
|
||||
FillEmpty bool `json:"fill_empty,omitempty"`
|
||||
// BucketLimit and BucketOffset page over the produced aggregate buckets, not
|
||||
// the underlying raw points. They are applied consistently across every
|
||||
// backend and aggregation type. The embedded Query.Limit/Query.Offset are
|
||||
@@ -417,10 +416,6 @@ type AggregatePoint struct {
|
||||
//
|
||||
// Count 是该桶代表的点数量。
|
||||
Count int `json:"count"`
|
||||
// Tags identify the logical series represented by the bucket.
|
||||
//
|
||||
// Tags 标识该聚合桶所属的逻辑序列。
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Stats stores or computes summary statistics for a point series.
|
||||
|
||||
+21
-21
@@ -4,27 +4,27 @@ import "time"
|
||||
|
||||
// ClientInfo is the legacy table shape for migrating pre-Client model data.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyMonitoringMigrationDoneKey = "migration_legacy_monitoring_to_metric_store_done"
|
||||
legacyMonitoringBatchSize = 500
|
||||
)
|
||||
|
||||
var legacyMonitoringTables = []string{"records", "records_long_term", "gpu_records", "ping_records"}
|
||||
|
||||
type MetricContext struct {
|
||||
DB *gorm.DB
|
||||
Store *metric.Store
|
||||
}
|
||||
|
||||
type LegacyMonitoringStats struct {
|
||||
Records int64
|
||||
GPU int64
|
||||
Ping int64
|
||||
}
|
||||
|
||||
// LegacyMonitoringSummary describes the legacy source data shown by the
|
||||
// pre-start upgrade wizard. Row counts deliberately stay separate from the
|
||||
// estimated metric point count because one legacy row expands into multiple
|
||||
// metric points.
|
||||
type LegacyMonitoringSummary struct {
|
||||
LoadRows int64 `json:"load_rows"`
|
||||
GPURows int64 `json:"gpu_rows"`
|
||||
LatencyRows int64 `json:"latency_rows"`
|
||||
MonitoringRows int64 `json:"monitoring_rows"`
|
||||
EstimatedPoints int64 `json:"estimated_points"`
|
||||
ServerCount int64 `json:"server_count"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
OldestAt *time.Time `json:"oldest_at,omitempty"`
|
||||
NewestAt *time.Time `json:"newest_at,omitempty"`
|
||||
}
|
||||
|
||||
type LegacyMonitoringProgress struct {
|
||||
Phase string `json:"phase"`
|
||||
Table string `json:"table,omitempty"`
|
||||
SourceRowsDone int64 `json:"source_rows_done"`
|
||||
SourceRowsTotal int64 `json:"source_rows_total"`
|
||||
WrittenPoints int64 `json:"written_points"`
|
||||
}
|
||||
|
||||
type LegacyMonitoringDeleted struct {
|
||||
LoadRows int64 `json:"load_rows"`
|
||||
GPURows int64 `json:"gpu_rows"`
|
||||
LatencyRows int64 `json:"latency_rows"`
|
||||
}
|
||||
|
||||
type legacyMonitoringBounds struct {
|
||||
Oldest string `gorm:"column:oldest"`
|
||||
Newest string `gorm:"column:newest"`
|
||||
}
|
||||
|
||||
type legacyBatchProgress func(table string, rows, points int64)
|
||||
|
||||
type legacyRecordRow struct {
|
||||
RowID int64 `gorm:"column:row_id"`
|
||||
models.Record
|
||||
}
|
||||
|
||||
type legacyGPURecordRow struct {
|
||||
RowID int64 `gorm:"column:row_id"`
|
||||
models.GPURecord
|
||||
}
|
||||
|
||||
type legacyPingRecordRow struct {
|
||||
RowID int64 `gorm:"column:row_id"`
|
||||
models.PingRecord
|
||||
}
|
||||
|
||||
// RunMetricStoreMigrations executes one-shot migrations that need the metric store
|
||||
// to be initialized. These cannot run inside Run because Run executes before the
|
||||
// metric store is opened.
|
||||
func RunMetricStoreMigrations(ctx MetricContext) error {
|
||||
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read legacy monitoring migration marker: %w", err)
|
||||
}
|
||||
|
||||
stats, err := runLegacyMonitoringMigration(context.Background(), ctx.DB, ctx.Store, done, func() error {
|
||||
return appconfig.Set(legacyMonitoringMigrationDoneKey, true)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.Records > 0 || stats.GPU > 0 || stats.Ping > 0 {
|
||||
log.Printf("Legacy monitoring table migration completed (records=%d, gpu=%d, ping=%d)", stats.Records, stats.GPU, stats.Ping)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LegacyMonitoringMigrationRequired reports whether startup must enter the
|
||||
// restricted 1.2.7 upgrade flow. Empty legacy tables are handled by the normal
|
||||
// one-shot migration so fresh installations never see the wizard.
|
||||
func LegacyMonitoringMigrationRequired(db *gorm.DB) (bool, LegacyMonitoringSummary, error) {
|
||||
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
|
||||
if err != nil {
|
||||
return false, LegacyMonitoringSummary{}, fmt.Errorf("read legacy monitoring migration marker: %w", err)
|
||||
}
|
||||
summary, err := InspectLegacyMonitoring(db)
|
||||
if err != nil {
|
||||
return false, LegacyMonitoringSummary{}, err
|
||||
}
|
||||
return !done && summary.MonitoringRows > 0, summary, nil
|
||||
}
|
||||
|
||||
func InspectLegacyMonitoring(db *gorm.DB) (LegacyMonitoringSummary, error) {
|
||||
var summary LegacyMonitoringSummary
|
||||
if db == nil {
|
||||
return summary, fmt.Errorf("migration database is nil")
|
||||
}
|
||||
if db.Migrator().HasTable(&models.Client{}) {
|
||||
if err := db.Model(&models.Client{}).Count(&summary.ServerCount).Error; err != nil {
|
||||
return summary, fmt.Errorf("count monitored servers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
counts := map[string]*int64{
|
||||
"records": &summary.LoadRows,
|
||||
"records_long_term": &summary.LoadRows,
|
||||
"gpu_records": &summary.GPURows,
|
||||
"ping_records": &summary.LatencyRows,
|
||||
}
|
||||
var oldest, newest time.Time
|
||||
for _, table := range legacyMonitoringTables {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table(table).Count(&count).Error; err != nil {
|
||||
return summary, fmt.Errorf("count legacy %s: %w", table, err)
|
||||
}
|
||||
*counts[table] += count
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
var bounds legacyMonitoringBounds
|
||||
if err := db.Table(table).Select("MIN(time) AS oldest, MAX(time) AS newest").Scan(&bounds).Error; err != nil {
|
||||
return summary, fmt.Errorf("inspect legacy %s time range: %w", table, err)
|
||||
}
|
||||
tableOldest, err := parseLegacyTimestamp(bounds.Oldest, time.UTC)
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("parse legacy %s oldest time: %w", table, err)
|
||||
}
|
||||
tableNewest, err := parseLegacyTimestamp(bounds.Newest, time.UTC)
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("parse legacy %s newest time: %w", table, err)
|
||||
}
|
||||
if oldest.IsZero() || (!tableOldest.IsZero() && tableOldest.Before(oldest)) {
|
||||
oldest = tableOldest
|
||||
}
|
||||
if newest.IsZero() || tableNewest.After(newest) {
|
||||
newest = tableNewest
|
||||
}
|
||||
}
|
||||
|
||||
summary.MonitoringRows = summary.LoadRows + summary.GPURows + summary.LatencyRows
|
||||
summary.EstimatedPoints = summary.LoadRows*19 + summary.GPURows*4 + summary.LatencyRows*2
|
||||
if !oldest.IsZero() {
|
||||
oldestCopy := oldest
|
||||
summary.OldestAt = &oldestCopy
|
||||
}
|
||||
if !newest.IsZero() {
|
||||
newestCopy := newest
|
||||
summary.NewestAt = &newestCopy
|
||||
}
|
||||
if !oldest.IsZero() && !newest.IsZero() {
|
||||
retentionEnd := time.Now().UTC()
|
||||
if newest.After(retentionEnd) {
|
||||
retentionEnd = newest
|
||||
}
|
||||
summary.RetentionDays = int(math.Ceil(retentionEnd.Sub(oldest).Hours() / 24))
|
||||
if summary.RetentionDays == 0 && summary.MonitoringRows > 0 {
|
||||
summary.RetentionDays = 1
|
||||
}
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// DeleteLegacyMonitoringBefore removes only monitoring history older than the
|
||||
// cutoff. Clients, users, ping tasks and other business data are untouched.
|
||||
func DeleteLegacyMonitoringBefore(db *gorm.DB, cutoff time.Time) (LegacyMonitoringDeleted, error) {
|
||||
var deleted LegacyMonitoringDeleted
|
||||
if db == nil {
|
||||
return deleted, fmt.Errorf("migration database is nil")
|
||||
}
|
||||
if cutoff.IsZero() {
|
||||
return deleted, fmt.Errorf("cleanup cutoff is required")
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, table := range legacyMonitoringTables {
|
||||
if !tx.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
result := tx.Exec("DELETE FROM "+table+" WHERE time < ?", cutoff.UTC())
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("delete legacy %s before cutoff: %w", table, result.Error)
|
||||
}
|
||||
switch table {
|
||||
case "records", "records_long_term":
|
||||
deleted.LoadRows += result.RowsAffected
|
||||
case "gpu_records":
|
||||
deleted.GPURows += result.RowsAffected
|
||||
case "ping_records":
|
||||
deleted.LatencyRows += result.RowsAffected
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
func MigrateLegacyMonitoring(ctx context.Context, db *gorm.DB, s *metric.Store, progress func(LegacyMonitoringProgress)) (LegacyMonitoringStats, error) {
|
||||
return migrateLegacyMonitoringTables(ctx, db, s, progress)
|
||||
}
|
||||
|
||||
// CompleteLegacyMonitoringMigration removes the legacy tables, runs any
|
||||
// caller-owned database maintenance, and only then persists the completion
|
||||
// marker. Keeping the marker last prevents a failed finalization from being
|
||||
// reported as a completed migration.
|
||||
func CompleteLegacyMonitoringMigration(db *gorm.DB, finalize func() error) error {
|
||||
if err := dropLegacyMonitoringTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if finalize != nil {
|
||||
if err := finalize(); err != nil {
|
||||
return fmt.Errorf("finalize legacy monitoring migration: %w", err)
|
||||
}
|
||||
}
|
||||
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, true); err != nil {
|
||||
return fmt.Errorf("mark legacy monitoring migration done: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLegacyMonitoringMigration(ctx context.Context, db *gorm.DB, s *metric.Store, done bool, markDone func() error) (LegacyMonitoringStats, error) {
|
||||
var stats LegacyMonitoringStats
|
||||
if done {
|
||||
return stats, nil
|
||||
}
|
||||
if db == nil {
|
||||
return stats, fmt.Errorf("migration database is nil")
|
||||
}
|
||||
if s == nil {
|
||||
return stats, fmt.Errorf("metric store is nil")
|
||||
}
|
||||
if markDone == nil {
|
||||
return stats, fmt.Errorf("migration marker writer is nil")
|
||||
}
|
||||
|
||||
stats, err := migrateLegacyMonitoringTables(ctx, db, s, nil)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if err := dropLegacyMonitoringTables(db); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if err := markDone(); err != nil {
|
||||
return stats, fmt.Errorf("mark legacy monitoring migration done: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func migrateLegacyMonitoringTables(ctx context.Context, db *gorm.DB, s *metric.Store, progress func(LegacyMonitoringProgress)) (LegacyMonitoringStats, error) {
|
||||
var stats LegacyMonitoringStats
|
||||
summary, err := InspectLegacyMonitoring(db)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
var rowsDone, writtenPoints int64
|
||||
emit := func(table string, rows, points int64) {
|
||||
rowsDone += rows
|
||||
writtenPoints += points
|
||||
if progress != nil {
|
||||
progress(LegacyMonitoringProgress{
|
||||
Phase: "migrating",
|
||||
Table: table,
|
||||
SourceRowsDone: rowsDone,
|
||||
SourceRowsTotal: summary.MonitoringRows,
|
||||
WrittenPoints: writtenPoints,
|
||||
})
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
emit("", 0, 0)
|
||||
}
|
||||
for _, table := range []string{"records", "records_long_term"} {
|
||||
n, err := migrateLegacyRecordTable(ctx, s, db, table, emit)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Records += n
|
||||
}
|
||||
|
||||
n, err := migrateLegacyGPURecordTable(ctx, s, db, "gpu_records", emit)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.GPU += n
|
||||
|
||||
n, err = migrateLegacyPingRecordTable(ctx, s, db, "ping_records", emit)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Ping += n
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func migrateLegacyRecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Table(table).Count(&total).Error; err != nil {
|
||||
return 0, fmt.Errorf("count legacy %s: %w", table, err)
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
|
||||
var migrated int64
|
||||
var lastRowID int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return migrated, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var rows []legacyRecordRow
|
||||
q := db.Table(table).Select("rowid AS row_id, *").Where("rowid > ?", lastRowID).Order("rowid ASC").Limit(legacyMonitoringBatchSize)
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return migrated, fmt.Errorf("read legacy %s: %w", table, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
points := make([]metric.Point, 0, len(rows)*19)
|
||||
for i := range rows {
|
||||
points = append(points, recordToPoints(rows[i].Record)...)
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
|
||||
}
|
||||
|
||||
last := rows[len(rows)-1]
|
||||
lastRowID = last.RowID
|
||||
migrated += int64(len(rows))
|
||||
if progress != nil {
|
||||
progress(table, int64(len(rows)), int64(len(points)))
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
func migrateLegacyGPURecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Table(table).Count(&total).Error; err != nil {
|
||||
return 0, fmt.Errorf("count legacy %s: %w", table, err)
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
|
||||
var migrated int64
|
||||
var lastRowID int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return migrated, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var rows []legacyGPURecordRow
|
||||
q := db.Table(table).Select("rowid AS row_id, *").Where("rowid > ?", lastRowID).Order("rowid ASC").Limit(legacyMonitoringBatchSize)
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return migrated, fmt.Errorf("read legacy %s: %w", table, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
points := make([]metric.Point, 0, len(rows)*4)
|
||||
for i := range rows {
|
||||
points = append(points, gpuRecordToPoints(rows[i].GPURecord)...)
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
|
||||
}
|
||||
|
||||
last := rows[len(rows)-1]
|
||||
lastRowID = last.RowID
|
||||
migrated += int64(len(rows))
|
||||
if progress != nil {
|
||||
progress(table, int64(len(rows)), int64(len(points)))
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
func migrateLegacyPingRecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Table(table).Count(&total).Error; err != nil {
|
||||
return 0, fmt.Errorf("count legacy %s: %w", table, err)
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
|
||||
var migrated int64
|
||||
var lastRowID int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return migrated, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var rows []legacyPingRecordRow
|
||||
q := db.Table(table).Select("rowid AS row_id, *").Where("rowid > ?", lastRowID).Order("rowid ASC").Limit(legacyMonitoringBatchSize)
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return migrated, fmt.Errorf("read legacy %s: %w", table, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
points := make([]metric.Point, 0, len(rows)*2)
|
||||
for i := range rows {
|
||||
points = append(points, pingRecordToPoints(rows[i].PingRecord)...)
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
|
||||
}
|
||||
|
||||
last := rows[len(rows)-1]
|
||||
lastRowID = last.RowID
|
||||
migrated += int64(len(rows))
|
||||
if progress != nil {
|
||||
progress(table, int64(len(rows)), int64(len(points)))
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
func recordToPoints(rec models.Record) []metric.Point {
|
||||
ts := rec.Time
|
||||
entityID := rec.Client
|
||||
return []metric.Point{
|
||||
{MetricName: metricstore.MetricCPU, EntityID: entityID, Timestamp: ts, Value: float64(rec.Cpu)},
|
||||
{MetricName: metricstore.MetricGPU, EntityID: entityID, Timestamp: ts, Value: float64(rec.Gpu)},
|
||||
{MetricName: metricstore.MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(rec.Ram)},
|
||||
{MetricName: metricstore.MetricRAMTotal, EntityID: entityID, Timestamp: ts, Value: float64(rec.RamTotal)},
|
||||
{MetricName: metricstore.MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(rec.Swap)},
|
||||
{MetricName: metricstore.MetricSwapTotal, EntityID: entityID, Timestamp: ts, Value: float64(rec.SwapTotal)},
|
||||
{MetricName: metricstore.MetricLoad, EntityID: entityID, Timestamp: ts, Value: float64(rec.Load)},
|
||||
{MetricName: metricstore.MetricTemp, EntityID: entityID, Timestamp: ts, Value: float64(rec.Temp)},
|
||||
{MetricName: metricstore.MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(rec.Disk)},
|
||||
{MetricName: metricstore.MetricDiskTotal, EntityID: entityID, Timestamp: ts, Value: float64(rec.DiskTotal)},
|
||||
{MetricName: metricstore.MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetIn)},
|
||||
{MetricName: metricstore.MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetOut)},
|
||||
{MetricName: metricstore.MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetTotalUp)},
|
||||
{MetricName: metricstore.MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetTotalDown)},
|
||||
{MetricName: metricstore.MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(rec.TrafficUp)},
|
||||
{MetricName: metricstore.MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(rec.TrafficDown)},
|
||||
{MetricName: metricstore.MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(rec.Process)},
|
||||
{MetricName: metricstore.MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(rec.Connections)},
|
||||
{MetricName: metricstore.MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(rec.ConnectionsUdp)},
|
||||
}
|
||||
}
|
||||
|
||||
func gpuRecordToPoints(rec models.GPURecord) []metric.Point {
|
||||
ts := rec.Time
|
||||
tags := map[string]string{
|
||||
"device_index": fmt.Sprintf("%d", rec.DeviceIndex),
|
||||
"device_name": rec.DeviceName,
|
||||
}
|
||||
return []metric.Point{
|
||||
{MetricName: metricstore.MetricGPUMem, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.MemUsed), Tags: tags},
|
||||
{MetricName: metricstore.MetricGPUMemTotal, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.MemTotal), Tags: tags},
|
||||
{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.Utilization), Tags: tags},
|
||||
{MetricName: metricstore.MetricGPUTemp, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.Temperature), Tags: tags},
|
||||
}
|
||||
}
|
||||
|
||||
func pingRecordToPoints(rec models.PingRecord) []metric.Point {
|
||||
ts := rec.Time
|
||||
tags := map[string]string{"task_id": fmt.Sprintf("%d", rec.TaskId)}
|
||||
loss := 0.0
|
||||
if rec.Value < 0 {
|
||||
loss = 1
|
||||
}
|
||||
return []metric.Point{
|
||||
{
|
||||
MetricName: metricstore.MetricPingLatency,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: ts,
|
||||
Value: float64(rec.Value),
|
||||
Tags: tags,
|
||||
},
|
||||
{
|
||||
MetricName: metricstore.MetricPingLoss,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: ts,
|
||||
Value: loss,
|
||||
Tags: tags,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dropLegacyMonitoringTables(db *gorm.DB) error {
|
||||
for _, table := range legacyMonitoringTables {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
log.Printf("[legacy-migration] dropping legacy table %s", table)
|
||||
if err := db.Migrator().DropTable(table); err != nil {
|
||||
return fmt.Errorf("drop legacy table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mainDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "komari.db"))+"?mode=rwc"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if sqlDB, err := mainDB.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
} else {
|
||||
t.Fatalf("legacy sql db: %v", err)
|
||||
}
|
||||
if err := mainDB.AutoMigrate(&models.Record{}, &models.GPURecord{}, &models.PingRecord{}); err != nil {
|
||||
t.Fatalf("migrate legacy tables: %v", err)
|
||||
}
|
||||
if err := mainDB.Table("records_long_term").AutoMigrate(&models.Record{}); err != nil {
|
||||
t.Fatalf("migrate legacy long-term table: %v", err)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 7, 8, 23, 42, 0, 0, time.UTC)
|
||||
if err := mainDB.Create(&models.Record{Client: "client-a", Time: base, Cpu: 12.5, Ram: 2048}).Error; err != nil {
|
||||
t.Fatalf("seed records: %v", err)
|
||||
}
|
||||
if err := mainDB.Table("records_long_term").Create(&models.Record{Client: "client-a", Time: base.Add(time.Minute), Cpu: 22.5, Ram: 4096}).Error; err != nil {
|
||||
t.Fatalf("seed records_long_term: %v", err)
|
||||
}
|
||||
if err := mainDB.Create(&models.GPURecord{Client: "client-a", Time: base, DeviceIndex: 0, DeviceName: "GPU 0", MemUsed: 1024, MemTotal: 2048, Utilization: 67, Temperature: 55}).Error; err != nil {
|
||||
t.Fatalf("seed gpu_records: %v", err)
|
||||
}
|
||||
if err := mainDB.Create(&models.PingRecord{Client: "client-a", TaskId: 7, Time: base, Value: 36}).Error; err != nil {
|
||||
t.Fatalf("seed ping_records: %v", err)
|
||||
}
|
||||
if err := mainDB.Create(&models.PingRecord{Client: "client-a", TaskId: 7, Time: base.Add(30 * time.Second), Value: -1}).Error; err != nil {
|
||||
t.Fatalf("seed loss ping_records: %v", err)
|
||||
}
|
||||
summary, err := InspectLegacyMonitoring(mainDB)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect legacy monitoring summary: %v", err)
|
||||
}
|
||||
if summary.LoadRows != 2 || summary.GPURows != 1 || summary.LatencyRows != 2 || summary.MonitoringRows != 5 {
|
||||
t.Fatalf("unexpected legacy monitoring summary: %#v", summary)
|
||||
}
|
||||
if summary.EstimatedPoints != 46 || summary.RetentionDays < 1 {
|
||||
t.Fatalf("unexpected legacy point estimate or retention: %#v", summary)
|
||||
}
|
||||
|
||||
metricDB, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "metrics.db"))+"?mode=rwc")
|
||||
if err != nil {
|
||||
t.Fatalf("open metric db: %v", err)
|
||||
}
|
||||
metricStore, err := metric.Open(ctx, metric.SQLite("", metric.WithDB(metricDB)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = metricStore.Close()
|
||||
_ = metricDB.Close()
|
||||
})
|
||||
|
||||
markDoneCalls := 0
|
||||
var lastProgress LegacyMonitoringProgress
|
||||
if _, err := MigrateLegacyMonitoring(ctx, mainDB, metricStore, func(progress LegacyMonitoringProgress) {
|
||||
lastProgress = progress
|
||||
}); err != nil {
|
||||
t.Fatalf("migrate legacy monitoring with progress: %v", err)
|
||||
}
|
||||
if lastProgress.SourceRowsDone != 5 || lastProgress.SourceRowsTotal != 5 || lastProgress.WrittenPoints != 46 {
|
||||
t.Fatalf("unexpected final migration progress: %#v", lastProgress)
|
||||
}
|
||||
|
||||
stats, err := runLegacyMonitoringMigration(ctx, mainDB, metricStore, false, func() error {
|
||||
markDoneCalls++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run legacy monitoring migration: %v", err)
|
||||
}
|
||||
if markDoneCalls != 1 {
|
||||
t.Fatalf("expected migration marker to be written once, got %d", markDoneCalls)
|
||||
}
|
||||
if stats.Records != 2 || stats.GPU != 1 || stats.Ping != 2 {
|
||||
t.Fatalf("unexpected stats: %#v", stats)
|
||||
}
|
||||
|
||||
cpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricCPU, EntityID: "client-a", Start: base.Add(-time.Second), End: base.Add(2 * time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatalf("query cpu points: %v", err)
|
||||
}
|
||||
if len(cpuPoints) != 2 || cpuPoints[0].Value != 12.5 || cpuPoints[1].Value != 22.5 {
|
||||
t.Fatalf("unexpected cpu points: %#v", cpuPoints)
|
||||
}
|
||||
|
||||
gpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: "client-a", Start: base.Add(-time.Second), End: base.Add(time.Second), Tags: map[string]string{"device_index": "0"}})
|
||||
if err != nil {
|
||||
t.Fatalf("query gpu points: %v", err)
|
||||
}
|
||||
if len(gpuPoints) != 1 || gpuPoints[0].Value != 67 {
|
||||
t.Fatalf("unexpected gpu points: %#v", gpuPoints)
|
||||
}
|
||||
|
||||
pingPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLatency, EntityID: "client-a", Start: base.Add(-time.Second), End: base.Add(time.Minute), Tags: map[string]string{"task_id": "7"}})
|
||||
if err != nil {
|
||||
t.Fatalf("query ping points: %v", err)
|
||||
}
|
||||
if len(pingPoints) != 2 || pingPoints[0].Value != 36 || pingPoints[1].Value != -1 {
|
||||
t.Fatalf("unexpected ping points: %#v", pingPoints)
|
||||
}
|
||||
|
||||
pingLossPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLoss, EntityID: "client-a", Start: base.Add(-time.Second), End: base.Add(time.Minute), Tags: map[string]string{"task_id": "7"}})
|
||||
if err != nil {
|
||||
t.Fatalf("query ping loss points: %v", err)
|
||||
}
|
||||
if len(pingLossPoints) != 2 || pingLossPoints[0].Value != 0 || pingLossPoints[1].Value != 1 {
|
||||
t.Fatalf("unexpected ping loss points: %#v", pingLossPoints)
|
||||
}
|
||||
|
||||
for _, table := range legacyMonitoringTables {
|
||||
if mainDB.Migrator().HasTable(table) {
|
||||
t.Fatalf("legacy table %s still exists", table)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err = runLegacyMonitoringMigration(ctx, mainDB, metricStore, true, func() error {
|
||||
markDoneCalls++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rerun completed legacy monitoring migration: %v", err)
|
||||
}
|
||||
if stats != (LegacyMonitoringStats{}) {
|
||||
t.Fatalf("completed migration should not scan legacy tables, got %#v", stats)
|
||||
}
|
||||
if markDoneCalls != 1 {
|
||||
t.Fatalf("completed migration rewrote marker, calls=%d", markDoneCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLegacyMonitoringBeforeOnlyRemovesOldHistory(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "cleanup.db"))+"?mode=rwc"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open cleanup db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
} else {
|
||||
t.Fatalf("cleanup sql db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Client{}, &models.Record{}, &models.GPURecord{}, &models.PingRecord{}); err != nil {
|
||||
t.Fatalf("migrate cleanup tables: %v", err)
|
||||
}
|
||||
if err := db.Table("records_long_term").AutoMigrate(&models.Record{}); err != nil {
|
||||
t.Fatalf("migrate cleanup long-term table: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", Name: "A"}).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
|
||||
oldTime := cutoff.Add(-time.Hour)
|
||||
newTime := cutoff.Add(time.Hour)
|
||||
for _, record := range []models.Record{{Client: "client-a", Time: oldTime}, {Client: "client-a", Time: newTime}} {
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatalf("seed load record: %v", err)
|
||||
}
|
||||
}
|
||||
for _, record := range []models.GPURecord{{Client: "client-a", Time: oldTime}, {Client: "client-a", Time: newTime}} {
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatalf("seed GPU record: %v", err)
|
||||
}
|
||||
}
|
||||
for _, record := range []models.PingRecord{{Client: "client-a", TaskId: 1, Time: oldTime}, {Client: "client-a", TaskId: 1, Time: newTime}} {
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatalf("seed ping record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := DeleteLegacyMonitoringBefore(db, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("delete legacy monitoring before cutoff: %v", err)
|
||||
}
|
||||
if deleted.LoadRows != 1 || deleted.GPURows != 1 || deleted.LatencyRows != 1 {
|
||||
t.Fatalf("unexpected deleted rows: %#v", deleted)
|
||||
}
|
||||
summary, err := InspectLegacyMonitoring(db)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect remaining legacy monitoring: %v", err)
|
||||
}
|
||||
if summary.LoadRows != 1 || summary.GPURows != 1 || summary.LatencyRows != 1 || summary.ServerCount != 1 {
|
||||
t.Fatalf("unexpected remaining legacy rows: %#v", summary)
|
||||
}
|
||||
var clients int64
|
||||
if err := db.Model(&models.Client{}).Count(&clients).Error; err != nil || clients != 1 {
|
||||
t.Fatalf("cleanup changed clients: count=%d err=%v", clients, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteLegacyMonitoringMigrationFinalizesBeforeMarkingDone(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "finalize.db"))+"?mode=rwc"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open finalization database: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
} else {
|
||||
t.Fatalf("finalization sql db: %v", err)
|
||||
}
|
||||
appconfig.SetDb(db)
|
||||
if err := db.AutoMigrate(&models.Record{}); err != nil {
|
||||
t.Fatalf("migrate legacy table: %v", err)
|
||||
}
|
||||
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, false); err != nil {
|
||||
t.Fatalf("reset completion marker: %v", err)
|
||||
}
|
||||
|
||||
finalized := false
|
||||
if err := CompleteLegacyMonitoringMigration(db, func() error {
|
||||
finalized = true
|
||||
if db.Migrator().HasTable(&models.Record{}) {
|
||||
t.Fatal("legacy table still exists during finalization")
|
||||
}
|
||||
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
t.Fatal("completion marker was written before finalization")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("complete legacy migration: %v", err)
|
||||
}
|
||||
if !finalized {
|
||||
t.Fatal("finalizer was not called")
|
||||
}
|
||||
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
|
||||
if err != nil {
|
||||
t.Fatalf("read completion marker: %v", err)
|
||||
}
|
||||
if !done {
|
||||
t.Fatal("completion marker was not written after finalization")
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.Record{}); err != nil {
|
||||
t.Fatalf("restore legacy table: %v", err)
|
||||
}
|
||||
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, false); err != nil {
|
||||
t.Fatalf("reset completion marker after success: %v", err)
|
||||
}
|
||||
finalizeErr := errors.New("vacuum failed")
|
||||
if err := CompleteLegacyMonitoringMigration(db, func() error { return finalizeErr }); !errors.Is(err, finalizeErr) {
|
||||
t.Fatalf("finalization error = %v, want %v", err, finalizeErr)
|
||||
}
|
||||
done, err = appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
|
||||
if err != nil {
|
||||
t.Fatalf("read completion marker after failure: %v", err)
|
||||
}
|
||||
if done {
|
||||
t.Fatal("failed finalization wrote the completion marker")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type legacyModelConfig struct {
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"`
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
@@ -39,8 +41,11 @@ type legacyModelConfig struct {
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"`
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"`
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RecordEnabled bool `json:"record_enabled" gorm:"default:true"`
|
||||
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"`
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"`
|
||||
CreatedAt models.LocalTime
|
||||
UpdatedAt models.LocalTime
|
||||
}
|
||||
|
||||
func (legacyModelConfig) TableName() string {
|
||||
@@ -61,9 +66,12 @@ type legacyConfig struct {
|
||||
BaseScriptsURLKey string `json:"base_scripts_url"`
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled"`
|
||||
GeoIpProvider string `json:"geo_ip_provider"`
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled"`
|
||||
OAuthProvider string `json:"o_auth_provider"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login"`
|
||||
CloudflareTunnelToken string `json:"cloudflare_tunnel_token"`
|
||||
CustomHead string `json:"custom_head"`
|
||||
CustomBody string `json:"custom_body"`
|
||||
NotificationEnabled bool `json:"notification_enabled"`
|
||||
@@ -73,6 +81,9 @@ type legacyConfig struct {
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"`
|
||||
LoginNotification bool `json:"login_notification"`
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"`
|
||||
RecordEnabled bool `json:"record_enabled"`
|
||||
RecordPreserveTime int `json:"record_preserve_time"`
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -97,9 +108,6 @@ func Run(ctx Context) error {
|
||||
}
|
||||
|
||||
legacyConfigTable := hasLegacyConfigTable(db)
|
||||
if err := migrateLegacyTimestampColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if legacyConfigTable {
|
||||
if err := migrateLegacyOidcConfig(db); err != nil {
|
||||
@@ -109,6 +117,7 @@ func Run(ctx Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateLegacyClientInfo(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,36 +132,10 @@ func Run(ctx Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := migrateDeprecatedMetricRetentionConfig(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateRemovedCompatibilityConfig(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := markTimestampMigrationDone(db); err != nil {
|
||||
return fmt.Errorf("mark UTC timestamp migration done: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDeprecatedMetricRetentionConfig(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Delete(&appconfig.ConfigItem{}, "key = ?", "metric_retention_days").Error
|
||||
}
|
||||
|
||||
func migrateRemovedCompatibilityConfig(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Delete(&appconfig.ConfigItem{}, "key IN ?", []string{
|
||||
"nezha_compat_enabled",
|
||||
"nezha_compat_listen",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func hasLegacyConfigTable(db *gorm.DB) bool {
|
||||
if !db.Migrator().HasTable("configs") {
|
||||
return false
|
||||
@@ -407,7 +390,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = info.ExpiredAt
|
||||
client.ExpiredAt = models.FromTime(info.ExpiredAt)
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
return fmt.Errorf("update Client record %s: %w", info.UUID, err)
|
||||
}
|
||||
|
||||
@@ -79,59 +79,6 @@ func TestRunSkipsLegacyConfigMigrationForCurrentConfigItemTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRemovesDeprecatedMetricRetentionConfig(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_remove_metric_retention")
|
||||
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
t.Fatalf("migrate config item table: %v", err)
|
||||
}
|
||||
if err := db.Create(&appconfig.ConfigItem{Key: "metric_retention_days", Value: "90"}).Error; err != nil {
|
||||
t.Fatalf("seed deprecated config: %v", err)
|
||||
}
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&appconfig.ConfigItem{}).Where("key = ?", "metric_retention_days").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count deprecated config: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("deprecated metric retention config was not removed: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRemovesCompatibilityConfig(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_remove_compatibility_config")
|
||||
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
t.Fatalf("migrate config item table: %v", err)
|
||||
}
|
||||
for _, key := range []string{"nezha_compat_enabled", "nezha_compat_listen"} {
|
||||
if err := db.Create(&appconfig.ConfigItem{Key: key, Value: "true"}).Error; err != nil {
|
||||
t.Fatalf("seed removed config %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&appconfig.ConfigItem{Key: "sitename", Value: `"Komari"`}).Error; err != nil {
|
||||
t.Fatalf("seed retained config: %v", err)
|
||||
}
|
||||
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&appconfig.ConfigItem{}).Where("key IN ?", []string{
|
||||
"nezha_compat_enabled",
|
||||
"nezha_compat_listen",
|
||||
}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count removed config: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("removed compatibility config remains: %d", count)
|
||||
}
|
||||
if err := db.First(&appconfig.ConfigItem{}, "key = ?", "sitename").Error; err != nil {
|
||||
t.Fatalf("unrelated config was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesVersion120RuntimeShape(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_v120_runtime_shape")
|
||||
if err := db.AutoMigrate(
|
||||
@@ -143,7 +90,7 @@ func TestRunPreservesVersion120RuntimeShape(t *testing.T) {
|
||||
); err != nil {
|
||||
t.Fatalf("migrate 1.2.0 runtime shape: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
now := models.FromTime(time.Now())
|
||||
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
@@ -222,6 +169,9 @@ func TestRunMigratesLegacyConfigTableToConfigItems(t *testing.T) {
|
||||
OAuthProvider: "github",
|
||||
NotificationMethod: "none",
|
||||
TrafficLimitPercentage: 66.5,
|
||||
RecordEnabled: true,
|
||||
RecordPreserveTime: 48,
|
||||
PingRecordPreserveTime: 12,
|
||||
ExpireNotificationLeadDays: 3,
|
||||
}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
@@ -268,7 +218,7 @@ func TestRunExpandsLegacyPingAllClientsTasks(t *testing.T) {
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy ping_tasks: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
now := models.FromTime(time.Now())
|
||||
clients := []models.Client{
|
||||
{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now},
|
||||
{UUID: "client-b", Token: "token-b", CreatedAt: now, UpdatedAt: now},
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appconfig "github.com/komari-monitor/komari/pkg/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const timestampUTCMigrationKey = "internal_timestamp_utc_migrated"
|
||||
|
||||
type timestampColumn struct {
|
||||
table string
|
||||
column string
|
||||
}
|
||||
|
||||
var legacyTimestampColumns = [...]timestampColumn{
|
||||
{table: "clients", column: "expired_at"},
|
||||
{table: "clients", column: "created_at"},
|
||||
{table: "clients", column: "updated_at"},
|
||||
{table: "client_infos", column: "expired_at"},
|
||||
{table: "client_infos", column: "created_at"},
|
||||
{table: "client_infos", column: "updated_at"},
|
||||
{table: "users", column: "created_at"},
|
||||
{table: "users", column: "updated_at"},
|
||||
{table: "sessions", column: "latest_online"},
|
||||
{table: "sessions", column: "expires"},
|
||||
{table: "sessions", column: "created_at"},
|
||||
{table: "logs", column: "time"},
|
||||
{table: "clipboards", column: "created_at"},
|
||||
{table: "clipboards", column: "updated_at"},
|
||||
{table: "offline_notifications", column: "last_notified"},
|
||||
{table: "load_notifications", column: "last_notified"},
|
||||
{table: "task_results", column: "finished_at"},
|
||||
{table: "task_results", column: "created_at"},
|
||||
{table: "records", column: "time"},
|
||||
{table: "records_long_term", column: "time"},
|
||||
{table: "gpu_records", column: "time"},
|
||||
{table: "ping_records", column: "time"},
|
||||
{table: "configs", column: "created_at"},
|
||||
{table: "configs", column: "updated_at"},
|
||||
}
|
||||
|
||||
var legacyTimestampLayouts = [...]string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
type timestampRow struct {
|
||||
rowID int64
|
||||
value sql.NullString
|
||||
}
|
||||
|
||||
// migrateLegacyTimestampColumns makes old offset-free SQLite values
|
||||
// unambiguous before any current model scans them as time.Time.
|
||||
func migrateLegacyTimestampColumns(db *gorm.DB) error {
|
||||
if timestampMigrationDone(db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
location := legacyTimestampLocation()
|
||||
var converted int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, target := range legacyTimestampColumns {
|
||||
if !tx.Migrator().HasTable(target.table) || !tx.Migrator().HasColumn(target.table, target.column) {
|
||||
continue
|
||||
}
|
||||
count, err := migrateTimestampColumn(tx, target, location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
converted += count
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if converted > 0 {
|
||||
log.Printf("Converted %d legacy timestamp values to explicit UTC", converted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTimestampColumn(db *gorm.DB, target timestampColumn, location *time.Location) (int64, error) {
|
||||
table := quoteSQLiteIdentifier(target.table)
|
||||
column := quoteSQLiteIdentifier(target.column)
|
||||
var converted int64
|
||||
var lastRowID int64
|
||||
|
||||
for {
|
||||
query := fmt.Sprintf(
|
||||
"SELECT rowid, CAST(%s AS TEXT) FROM %s WHERE rowid > ? AND %s IS NOT NULL ORDER BY rowid LIMIT 1000",
|
||||
column, table, column,
|
||||
)
|
||||
rows, err := db.Raw(query, lastRowID).Rows()
|
||||
if err != nil {
|
||||
return converted, fmt.Errorf("read legacy timestamp %s.%s: %w", target.table, target.column, err)
|
||||
}
|
||||
|
||||
batch := make([]timestampRow, 0, 1000)
|
||||
for rows.Next() {
|
||||
var row timestampRow
|
||||
if err := rows.Scan(&row.rowID, &row.value); err != nil {
|
||||
rows.Close()
|
||||
return converted, fmt.Errorf("scan legacy timestamp %s.%s: %w", target.table, target.column, err)
|
||||
}
|
||||
batch = append(batch, row)
|
||||
lastRowID = row.rowID
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return converted, fmt.Errorf("iterate legacy timestamp %s.%s: %w", target.table, target.column, err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, row := range batch {
|
||||
raw := strings.TrimSpace(row.value.String)
|
||||
if !row.value.Valid || raw == "" {
|
||||
continue
|
||||
}
|
||||
stamp, err := parseLegacyTimestamp(raw, location)
|
||||
if err != nil {
|
||||
return converted, fmt.Errorf("convert legacy timestamp %s.%s rowid=%d: %w", target.table, target.column, row.rowID, err)
|
||||
}
|
||||
update := fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, column)
|
||||
if err := db.Exec(update, stamp.UTC(), row.rowID).Error; err != nil {
|
||||
return converted, fmt.Errorf("write UTC timestamp %s.%s rowid=%d: %w", target.table, target.column, row.rowID, err)
|
||||
}
|
||||
converted++
|
||||
}
|
||||
if len(batch) < 1000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
func parseLegacyTimestamp(value string, location *time.Location) (time.Time, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("timestamp is empty")
|
||||
}
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
if stamp, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return stamp.UTC(), nil
|
||||
}
|
||||
for _, layout := range legacyTimestampLayouts {
|
||||
if stamp, err := time.ParseInLocation(layout, raw, location); err == nil {
|
||||
return stamp.UTC(), nil
|
||||
}
|
||||
}
|
||||
epoch, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("unsupported legacy timestamp %q", value)
|
||||
}
|
||||
return legacyEpochTime(epoch), nil
|
||||
}
|
||||
|
||||
func legacyEpochTime(value int64) time.Time {
|
||||
abs := value
|
||||
if abs < 0 {
|
||||
if abs == math.MinInt64 {
|
||||
return time.Unix(0, value).UTC()
|
||||
}
|
||||
abs = -abs
|
||||
}
|
||||
switch {
|
||||
case abs >= 1e17:
|
||||
return time.Unix(0, value).UTC()
|
||||
case abs >= 1e14:
|
||||
return time.Unix(0, value*int64(time.Microsecond)).UTC()
|
||||
case abs >= 1e11:
|
||||
return time.UnixMilli(value).UTC()
|
||||
default:
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
}
|
||||
|
||||
// legacyTimestampLocation is intentionally migration-only. The old custom
|
||||
// type used TZ and defaulted to UTC; current runtime code uses system time.Local.
|
||||
func legacyTimestampLocation() *time.Location {
|
||||
name := strings.TrimSpace(os.Getenv("TZ"))
|
||||
if name == "" {
|
||||
return time.UTC
|
||||
}
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
log.Printf("Legacy timezone %q cannot be loaded; interpreting old timestamps as UTC: %v", name, err)
|
||||
return time.UTC
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func timestampMigrationDone(db *gorm.DB) bool {
|
||||
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) || hasLegacyConfigTable(db) {
|
||||
return false
|
||||
}
|
||||
var item appconfig.ConfigItem
|
||||
if err := db.Where("key = ?", timestampUTCMigrationKey).First(&item).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return item.Value == "true"
|
||||
}
|
||||
|
||||
func markTimestampMigrationDone(db *gorm.DB) error {
|
||||
if hasLegacyConfigTable(db) {
|
||||
return fmt.Errorf("new config item table is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
|
||||
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
return fmt.Errorf("create config item table: %w", err)
|
||||
}
|
||||
}
|
||||
item := appconfig.ConfigItem{Key: timestampUTCMigrationKey, Value: "true"}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&item).Error
|
||||
}
|
||||
|
||||
func quoteSQLiteIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestLegacyTimestampMigrationDefaultsToUTC(t *testing.T) {
|
||||
t.Setenv("TZ", "")
|
||||
db := openTimestampMigrationDB(t)
|
||||
insertLegacyLogTime(t, db, "2026-07-17 09:30:00.1234567")
|
||||
|
||||
if err := migrateLegacyTimestampColumns(db); err != nil {
|
||||
t.Fatalf("migrate timestamps: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 7, 17, 9, 30, 0, 123456700, time.UTC)
|
||||
assertMigratedLogTime(t, db, want)
|
||||
}
|
||||
|
||||
func TestLegacyTimestampMigrationUsesLegacyTZ(t *testing.T) {
|
||||
t.Setenv("TZ", "Asia/Shanghai")
|
||||
db := openTimestampMigrationDB(t)
|
||||
insertLegacyLogTime(t, db, "2026-07-17 09:30:00.123456789")
|
||||
|
||||
if err := migrateLegacyTimestampColumns(db); err != nil {
|
||||
t.Fatalf("migrate timestamps: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 7, 17, 1, 30, 0, 123456789, time.UTC)
|
||||
assertMigratedLogTime(t, db, want)
|
||||
}
|
||||
|
||||
func TestParseLegacyTimestampAcceptsHistoricalEpochUnits(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 17, 1, 30, 0, 123456789, time.UTC)
|
||||
tests := map[string]struct {
|
||||
value string
|
||||
want time.Time
|
||||
}{
|
||||
"seconds": {value: fmt.Sprint(stamp.Unix()), want: stamp.Truncate(time.Second)},
|
||||
"milliseconds": {value: fmt.Sprint(stamp.UnixMilli()), want: stamp.Truncate(time.Millisecond)},
|
||||
"microseconds": {value: fmt.Sprint(stamp.UnixMicro()), want: stamp.Truncate(time.Microsecond)},
|
||||
"nanoseconds": {value: fmt.Sprint(stamp.UnixNano()), want: stamp},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := parseLegacyTimestamp(test.value, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse legacy timestamp: %v", err)
|
||||
}
|
||||
if !got.Equal(test.want) {
|
||||
t.Fatalf("parsed timestamp = %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func openTimestampMigrationDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE logs (id INTEGER PRIMARY KEY, time TIMESTAMP)`).Error; err != nil {
|
||||
t.Fatalf("create logs table: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func insertLegacyLogTime(t *testing.T, db *gorm.DB, value string) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`INSERT INTO logs (time) VALUES (?)`, value).Error; err != nil {
|
||||
t.Fatalf("insert legacy timestamp: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMigratedLogTime(t *testing.T, db *gorm.DB, want time.Time) {
|
||||
t.Helper()
|
||||
var got time.Time
|
||||
if err := db.Raw(`SELECT time FROM logs LIMIT 1`).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("read migrated timestamp: %v", err)
|
||||
}
|
||||
if !got.Equal(want) || got.Nanosecond() != want.Nanosecond() {
|
||||
t.Fatalf("migrated time = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
var raw string
|
||||
if err := db.Raw(`SELECT CAST(time AS TEXT) FROM logs LIMIT 1`).Scan(&raw).Error; err != nil {
|
||||
t.Fatalf("read raw migrated timestamp: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(raw, "+00:00") {
|
||||
t.Fatalf("stored timestamp %q has no explicit UTC offset", raw)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SameSystemDate compares calendar dates in the operating system timezone.
|
||||
func SameSystemDate(left, right time.Time) bool {
|
||||
left = left.In(time.Local)
|
||||
right = right.In(time.Local)
|
||||
ly, lm, ld := left.Date()
|
||||
ry, rm, rd := right.Date()
|
||||
return ly == ry && lm == rm && ld == rd
|
||||
}
|
||||
|
||||
// FormatSystemDate formats an instant as a system-local calendar date.
|
||||
func FormatSystemDate(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.In(time.Local).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// SystemDateDistance returns the number of local calendar boundaries between
|
||||
// two instants, independent of daylight-saving day length.
|
||||
func SystemDateDistance(from, to time.Time) int {
|
||||
fy, fm, fd := from.In(time.Local).Date()
|
||||
ty, tm, td := to.In(time.Local).Date()
|
||||
fromDate := time.Date(fy, fm, fd, 0, 0, 0, 0, time.UTC)
|
||||
toDate := time.Date(ty, tm, td, 0, 0, 0, 0, time.UTC)
|
||||
return int(toDate.Sub(fromDate) / (24 * time.Hour))
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSystemDateHelpersUseSystemLocal(t *testing.T) {
|
||||
originalLocal := time.Local
|
||||
time.Local = time.FixedZone("UTC+8", 8*60*60)
|
||||
t.Cleanup(func() { time.Local = originalLocal })
|
||||
|
||||
lateUTC := time.Date(2026, 7, 17, 16, 30, 0, 0, time.UTC)
|
||||
earlyUTC := time.Date(2026, 7, 18, 1, 30, 0, 0, time.UTC)
|
||||
if !SameSystemDate(lateUTC, earlyUTC) {
|
||||
t.Fatal("instants on the same system-local date should match")
|
||||
}
|
||||
if got := FormatSystemDate(lateUTC); got != "2026-07-18" {
|
||||
t.Fatalf("formatted system date = %q, want 2026-07-18", got)
|
||||
}
|
||||
if got := SystemDateDistance(lateUTC.AddDate(0, 0, -2), lateUTC); got != 2 {
|
||||
t.Fatalf("system date distance = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSystemDateReturnsEmptyForZeroTime(t *testing.T) {
|
||||
if got := FormatSystemDate(time.Time{}); got != "" {
|
||||
t.Fatalf("formatted zero time = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
+10
-14
@@ -1,10 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
import v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
|
||||
const (
|
||||
Version = "2.0"
|
||||
@@ -35,11 +31,11 @@ type Response struct {
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
@@ -58,10 +54,10 @@ type BasicInfoParams struct {
|
||||
}
|
||||
|
||||
type PingResultParams struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
PingType string `json:"ping_type"`
|
||||
Value int `json:"value"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
TaskID uint `json:"task_id"`
|
||||
PingType string `json:"ping_type"`
|
||||
Value int `json:"value"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
}
|
||||
|
||||
type PullParams struct {
|
||||
|
||||
+11
-14
@@ -1,9 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
MethodNetworkTestNextTrace = "networkTest.nextTrace"
|
||||
@@ -62,8 +59,8 @@ type NextTraceResult struct {
|
||||
TargetHost string `json:"target_host"`
|
||||
IPFamily IPFamily `json:"ip_family"`
|
||||
Protocol TraceProtocol `json:"protocol"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
@@ -105,11 +102,11 @@ type MeshTraceParams struct {
|
||||
}
|
||||
|
||||
type MeshTraceAccepted struct {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
TotalPairs int `json:"total_pairs"`
|
||||
AcceptedAt time.Time `json:"accepted_at"`
|
||||
PollIntervalMs int `json:"poll_interval_ms"`
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
TotalPairs int `json:"total_pairs"`
|
||||
AcceptedAt string `json:"accepted_at"`
|
||||
PollIntervalMs int `json:"poll_interval_ms"`
|
||||
}
|
||||
|
||||
type MeshTraceJobSnapshot struct {
|
||||
@@ -120,8 +117,8 @@ type MeshTraceJobSnapshot struct {
|
||||
Failed int `json:"failed"`
|
||||
Running int `json:"running"`
|
||||
Results []NextTraceResult `json:"results"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNetworkTestMethodConstants(t *testing.T) {
|
||||
@@ -64,7 +63,6 @@ func TestNextTraceParamsJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNextTraceResultJSON(t *testing.T) {
|
||||
startedAt := time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC)
|
||||
result := NextTraceResult{
|
||||
TaskID: "trace-1",
|
||||
SourceID: "agent-a",
|
||||
@@ -72,8 +70,8 @@ func TestNextTraceResultJSON(t *testing.T) {
|
||||
TargetHost: "203.0.113.10",
|
||||
IPFamily: IPFamilyIPv4,
|
||||
Protocol: TraceProtocolICMP,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: startedAt.Add(time.Second),
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
FinishedAt: "2026-06-05T10:00:01Z",
|
||||
DurationMs: 1000,
|
||||
OK: true,
|
||||
Error: "",
|
||||
@@ -171,7 +169,6 @@ func TestMeshTraceParamsJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMeshTraceJobSnapshotJSON(t *testing.T) {
|
||||
startedAt := time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC)
|
||||
snapshot := MeshTraceJobSnapshot{
|
||||
JobID: "mesh-1",
|
||||
Status: "running",
|
||||
@@ -187,8 +184,8 @@ func TestMeshTraceJobSnapshotJSON(t *testing.T) {
|
||||
TargetHost: "203.0.113.10",
|
||||
IPFamily: IPFamilyIPv4,
|
||||
Protocol: TraceProtocolUDP,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: startedAt.Add(time.Second),
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
FinishedAt: "2026-06-05T10:00:01Z",
|
||||
DurationMs: 1000,
|
||||
OK: false,
|
||||
Error: "timeout",
|
||||
@@ -200,9 +197,9 @@ func TestMeshTraceJobSnapshotJSON(t *testing.T) {
|
||||
Truncated: false,
|
||||
},
|
||||
},
|
||||
StartedAt: startedAt,
|
||||
UpdatedAt: startedAt.Add(time.Second),
|
||||
FinishedAt: nil,
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
UpdatedAt: "2026-06-05T10:00:01Z",
|
||||
FinishedAt: "",
|
||||
Error: "",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/secureconfig"
|
||||
)
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
Running bool `json:"running"`
|
||||
Message string `json:"message"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Logs []string `json:"logs"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
BinaryPath string `json:"binaryPath,omitempty"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
EnvTokenPresent bool `json:"envTokenPresent"`
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
running bool
|
||||
stopRequested bool
|
||||
message string
|
||||
errorMessage string
|
||||
logs []string
|
||||
maxLogLines int
|
||||
}
|
||||
|
||||
var defaultManager = &manager{
|
||||
maxLogLines: 80,
|
||||
message: "cloudflared is not running",
|
||||
}
|
||||
|
||||
func Status() RuntimeStatus {
|
||||
return defaultManager.status()
|
||||
}
|
||||
|
||||
func Start(token string) error {
|
||||
return defaultManager.start(token)
|
||||
}
|
||||
|
||||
func Stop() error {
|
||||
return defaultManager.stop()
|
||||
}
|
||||
|
||||
func Shutdown() {
|
||||
if err := defaultManager.stop(); err != nil {
|
||||
log.Printf("failed to stop cloudflared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func SaveToken(token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return config.Set(config.CloudflareTunnelTokenKey, "")
|
||||
}
|
||||
|
||||
encrypted, err := secureconfig.EncryptString(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return config.Set(config.CloudflareTunnelTokenKey, encrypted)
|
||||
}
|
||||
|
||||
func RemoveToken() error {
|
||||
if defaultManager.status().Running {
|
||||
return errors.New("stop cloudflared before removing the token")
|
||||
}
|
||||
return config.Set(config.CloudflareTunnelTokenKey, "")
|
||||
}
|
||||
|
||||
func LoadToken() (token string, err error) {
|
||||
if envToken := strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_TOKEN")); envToken != "" {
|
||||
return envToken, nil
|
||||
}
|
||||
|
||||
return loadStoredToken()
|
||||
}
|
||||
|
||||
func AutoStart(envToken string) error {
|
||||
token := strings.TrimSpace(envToken)
|
||||
if token == "" {
|
||||
var err error
|
||||
token, err = LoadToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := SaveToken(token); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("[cloudflared] using token from KOMARI_CLOUDFLARED_TOKEN")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := Start(token); err != nil {
|
||||
defaultManager.setError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) start(token string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.running {
|
||||
return errors.New("cloudflared is already running")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(token) == "" {
|
||||
var err error
|
||||
token, err = LoadToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load Cloudflare Tunnel token: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return errors.New("cloudflare tunnel token is not configured")
|
||||
}
|
||||
|
||||
binaryPath, installed := resolveBinaryPath()
|
||||
if !installed {
|
||||
return errors.New("cloudflared is not installed; install it manually or use the Docker image with built-in cloudflared")
|
||||
}
|
||||
|
||||
cmd := exec.Command(binaryPath, "tunnel", "--no-autoupdate", "run")
|
||||
cmd.Env = append(os.Environ(), "TUNNEL_TOKEN="+token)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.cmd = cmd
|
||||
m.done = make(chan struct{})
|
||||
m.running = true
|
||||
m.stopRequested = false
|
||||
m.errorMessage = ""
|
||||
m.message = "cloudflared is running"
|
||||
m.appendLogLocked(fmt.Sprintf("started cloudflared (pid=%d)", cmd.Process.Pid))
|
||||
|
||||
go m.scanPipe(stdout, false)
|
||||
go m.scanPipe(stderr, true)
|
||||
go m.waitProcess(cmd, m.done)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) stop() error {
|
||||
m.mu.RLock()
|
||||
cmd := m.cmd
|
||||
done := m.done
|
||||
running := m.running
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !running || cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.stopRequested = true
|
||||
m.message = "stopping cloudflared..."
|
||||
m.mu.Unlock()
|
||||
|
||||
var signalErr error
|
||||
if runtime.GOOS == "windows" {
|
||||
signalErr = cmd.Process.Kill()
|
||||
} else {
|
||||
signalErr = cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
if signalErr != nil {
|
||||
return signalErr
|
||||
}
|
||||
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) waitProcess(cmd *exec.Cmd, done chan struct{}) {
|
||||
err := cmd.Wait()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if m.stopRequested {
|
||||
m.message = "cloudflared stopped"
|
||||
m.errorMessage = ""
|
||||
m.appendLogLocked("cloudflared stopped")
|
||||
} else {
|
||||
m.message = "cloudflared exited unexpectedly"
|
||||
m.errorMessage = err.Error()
|
||||
m.appendLogLocked("cloudflared exited unexpectedly: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
m.message = "cloudflared stopped"
|
||||
if !m.stopRequested {
|
||||
m.errorMessage = ""
|
||||
}
|
||||
m.appendLogLocked("cloudflared stopped")
|
||||
}
|
||||
|
||||
m.cmd = nil
|
||||
m.running = false
|
||||
m.stopRequested = false
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (m *manager) scanPipe(reader io.ReadCloser, isErr bool) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if isErr || looksLikeError(line) {
|
||||
m.errorMessage = line
|
||||
}
|
||||
m.message = line
|
||||
m.appendLogLocked(line)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) status() RuntimeStatus {
|
||||
binaryPath, installed := resolveBinaryPath()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
status := RuntimeStatus{
|
||||
Installed: installed,
|
||||
Running: m.running,
|
||||
Message: m.message,
|
||||
ErrorMessage: m.errorMessage,
|
||||
Logs: append([]string{}, m.logs...),
|
||||
BinaryPath: binaryPath,
|
||||
EnvTokenPresent: strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_TOKEN")) != "",
|
||||
}
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
status.PID = m.cmd.Process.Pid
|
||||
}
|
||||
|
||||
token, err := loadStoredToken()
|
||||
if err == nil && strings.TrimSpace(token) != "" {
|
||||
status.TokenStored = true
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (m *manager) setError(message string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errorMessage = message
|
||||
m.message = message
|
||||
m.appendLogLocked(message)
|
||||
}
|
||||
|
||||
func (m *manager) appendLogLocked(line string) {
|
||||
m.logs = append(m.logs, line)
|
||||
if len(m.logs) > m.maxLogLines {
|
||||
m.logs = m.logs[len(m.logs)-m.maxLogLines:]
|
||||
}
|
||||
}
|
||||
|
||||
func loadStoredToken() (token string, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
token = ""
|
||||
err = nil
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := config.GetAs[string](config.CloudflareTunnelTokenKey, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
token, err = secureconfig.DecryptString(raw)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func resolveBinaryPath() (string, bool) {
|
||||
candidates := []string{}
|
||||
if envPath := strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_BIN")); envPath != "" {
|
||||
candidates = append(candidates, envPath)
|
||||
}
|
||||
|
||||
candidates = append(candidates, "cloudflared")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join("data", "cloudflared.exe"),
|
||||
filepath.Join("data", "bin", "cloudflared.exe"),
|
||||
)
|
||||
} else {
|
||||
candidates = append(candidates,
|
||||
filepath.Join("data", "cloudflared"),
|
||||
filepath.Join("data", "bin", "cloudflared"),
|
||||
"/usr/local/bin/cloudflared",
|
||||
"/usr/bin/cloudflared",
|
||||
)
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
|
||||
if path, err := exec.LookPath(candidate); err == nil {
|
||||
return path, true
|
||||
}
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
if abs, absErr := filepath.Abs(candidate); absErr == nil {
|
||||
return abs, true
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func looksLikeError(line string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
return strings.Contains(lower, "error") ||
|
||||
strings.Contains(lower, "failed") ||
|
||||
strings.Contains(lower, "invalid") ||
|
||||
strings.Contains(lower, "unauthorized")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cloudflared_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
)
|
||||
|
||||
func TestStatusDoesNotExposeToken(t *testing.T) {
|
||||
const token = "test-cloudflare-token"
|
||||
t.Setenv("KOMARI_CLOUDFLARED_TOKEN", token)
|
||||
|
||||
status := cloudflared.Status()
|
||||
if !status.EnvTokenPresent {
|
||||
t.Fatalf("expected environment token to be detected")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal status: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(payload), token) {
|
||||
t.Fatalf("expected status payload not to contain the raw token")
|
||||
}
|
||||
}
|
||||
@@ -115,14 +115,6 @@ func InitGeoIp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown 关闭当前 GeoIP provider 持有的资源(如 mmdb 文件句柄)。供关闭流程调用。
|
||||
func Shutdown() error {
|
||||
if CurrentProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return CurrentProvider.Close()
|
||||
}
|
||||
|
||||
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
providerName := CurrentProvider.Name()
|
||||
cacheKey := providerName + ":" + ip.String()
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ func GinLogger() gin.HandlerFunc {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
r := slog.NewRecord(time.Now().UTC(), level, msg, 0)
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func GinRecovery() gin.HandlerFunc {
|
||||
if err := recover(); err != nil {
|
||||
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
|
||||
handler := slog.Default().Handler()
|
||||
r := slog.NewRecord(time.Now().UTC(), slog.LevelError, msg, 0)
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN"))
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
c.AbortWithStatus(500)
|
||||
|
||||
+3
-3
@@ -74,19 +74,19 @@ func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (stri
|
||||
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now().UTC(), slog.LevelError, msg, 0)
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now().UTC(), slog.LevelWarn, msg, 0)
|
||||
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case l.LogLevel >= gormlogger.Info:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now().UTC(), slog.LevelDebug, msg, 0)
|
||||
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
line = f.Line
|
||||
}
|
||||
|
||||
timeStr := r.Time.UTC().Format("2006/01/02 15:04:05")
|
||||
timeStr := r.Time.Format("2006/01/02 15:04:05")
|
||||
|
||||
// 检查是否有分组信息(从 attributes 中获取)
|
||||
group := h.group
|
||||
@@ -169,7 +169,7 @@ func (w *writerAdapter) Write(p []byte) (n int, err error) {
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
|
||||
|
||||
r := slog.NewRecord(time.Now().UTC(), w.level, msg, pcs[0])
|
||||
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
|
||||
return len(p), w.handler.Handle(context.Background(), r)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,6 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
}
|
||||
|
||||
// Compose headers
|
||||
now := time.Now().UTC()
|
||||
headers := []string{
|
||||
"To: " + strings.Join(rcptHeaderParts, ", "),
|
||||
"From: " + senderHeader,
|
||||
@@ -186,8 +185,8 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: " + contentType,
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Date: " + now.Format(time.RFC1123Z),
|
||||
fmt.Sprintf("Message-ID: <%d@%s>", now.UnixNano(), e.Addition.Host),
|
||||
"Date: " + time.Now().Format(time.RFC1123Z),
|
||||
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
|
||||
}
|
||||
|
||||
fullMsg := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyBuf.String())
|
||||
|
||||
@@ -262,7 +262,7 @@ func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) erro
|
||||
event.Emoji, event.Emoji, event.Emoji,
|
||||
event.Event,
|
||||
event.Message,
|
||||
event.Time.UTC().Format(time.RFC3339Nano))
|
||||
event.Time.Format(time.RFC3339))
|
||||
|
||||
// 添加客户端信息
|
||||
if len(event.Clients) > 0 {
|
||||
@@ -279,7 +279,7 @@ func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) erro
|
||||
event.Event,
|
||||
clientNames,
|
||||
event.Message,
|
||||
event.Time.UTC().Format(time.RFC3339Nano))
|
||||
event.Time.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return j.SendTextMessage(message, event.Event)
|
||||
|
||||
@@ -2,22 +2,10 @@ package messageSender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
func TestParseTemplateFormatsEventTimeAsUTCNanoseconds(t *testing.T) {
|
||||
local := time.FixedZone("UTC+8", 8*60*60)
|
||||
eventTime := time.Date(2026, 7, 17, 9, 30, 0, 123456789, local)
|
||||
got := parseTemplate("{{time}}", models.EventMessage{Time: eventTime})
|
||||
want := "2026-07-17T01:30:00.123456789Z"
|
||||
if got != want {
|
||||
t.Fatalf("formatted event time = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
senders := factory.GetAllMessageSenders()
|
||||
if len(senders) == 0 {
|
||||
|
||||
@@ -27,18 +27,6 @@ func CurrentProvider() factory.IMessageSender {
|
||||
return currentProvider
|
||||
}
|
||||
|
||||
// Shutdown 销毁当前消息发送 provider,释放其持有的资源。供关闭流程调用。
|
||||
func Shutdown() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if currentProvider == nil {
|
||||
return nil
|
||||
}
|
||||
err := currentProvider.Destroy()
|
||||
currentProvider = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func Initialize() {
|
||||
go func() {
|
||||
once.Do(func() {
|
||||
@@ -107,11 +95,6 @@ func SendEvent(event models.EventMessage) error {
|
||||
if CurrentProvider() == nil {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
if event.Time.IsZero() {
|
||||
event.Time = time.Now().UTC()
|
||||
} else {
|
||||
event.Time = event.Time.UTC()
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.NotificationEnabledKey: false,
|
||||
@@ -170,7 +153,7 @@ func parseTemplate(messageTemplate string, event models.EventMessage) string {
|
||||
replaceMap := map[string]string{
|
||||
"{{event}}": event.Event,
|
||||
"{{client}}": joinedClients,
|
||||
"{{time}}": event.Time.UTC().Format(time.RFC3339Nano),
|
||||
"{{time}}": event.Time.Format(time.RFC3339),
|
||||
"{{message}}": event.Message,
|
||||
"{{emoji}}": event.Emoji,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/timeutil"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/renewal"
|
||||
)
|
||||
@@ -31,7 +31,7 @@ func CheckExpire() {
|
||||
return
|
||||
}
|
||||
|
||||
checkTime := time.Now().UTC()
|
||||
checkTime := time.Now()
|
||||
|
||||
// 过期提醒检查(仅当启用过期通知时)
|
||||
if cfg[config.ExpireNotificationEnabledKey].(bool) {
|
||||
@@ -45,19 +45,17 @@ func CheckExpire() {
|
||||
var clientLeadToExpire []clientToExpireInfo
|
||||
|
||||
for _, client := range clients_all {
|
||||
if client.ExpiredAt == nil {
|
||||
continue
|
||||
}
|
||||
clientExpireTime := client.ExpiredAt.UTC()
|
||||
clientExpireTime := client.ExpiredAt.ToTime()
|
||||
|
||||
if clientExpireTime.Before(checkTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
notificationThreshold := checkTime.In(time.Local).AddDate(0, 0, notificationLeadDays).UTC()
|
||||
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
|
||||
|
||||
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
|
||||
daysLeft := timeutil.SystemDateDistance(checkTime, clientExpireTime)
|
||||
remainingDuration := clientExpireTime.Sub(checkTime)
|
||||
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
|
||||
|
||||
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
|
||||
Name: client.Name,
|
||||
@@ -73,7 +71,7 @@ func CheckExpire() {
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Expire,
|
||||
Time: time.Now().UTC(),
|
||||
Time: time.Now(),
|
||||
Message: message,
|
||||
Emoji: "⏳",
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ func executeLoadNotificationTask(task models.LoadNotification) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-time.Duration(task.Interval) * time.Minute)
|
||||
overloadClients := make([]string, 0)
|
||||
for _, clientUUID := range task.Clients {
|
||||
@@ -86,13 +86,13 @@ func executeLoadNotificationTask(task models.LoadNotification) {
|
||||
|
||||
// shouldSkipNotification 检查是否应该跳过通知(冷却期检查)
|
||||
func shouldSkipNotification(task models.LoadNotification) bool {
|
||||
if task.LastNotified == nil || task.LastNotified.IsZero() {
|
||||
if task.LastNotified.ToTime().IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 计算冷却期(使用 interval 作为冷却期)
|
||||
cooldownPeriod := time.Duration(task.Interval) * time.Minute
|
||||
timeSinceLastNotified := time.Since(*task.LastNotified)
|
||||
timeSinceLastNotified := time.Since(task.LastNotified.ToTime())
|
||||
|
||||
return timeSinceLastNotified < cooldownPeriod
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Alert,
|
||||
Clients: ex_clients,
|
||||
Time: time.Now().UTC(),
|
||||
Time: time.Now(),
|
||||
Emoji: "⚠️",
|
||||
Message: task.Name,
|
||||
})
|
||||
@@ -224,7 +224,7 @@ func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
|
||||
// updateLastNotified 更新最后通知时间
|
||||
func updateLastNotified(taskId uint, notifyTime time.Time) {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime.UTC()).Error; err != nil {
|
||||
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime).Error; err != nil {
|
||||
log.Printf("Failed to update last_notified for task %d: %v", taskId, err)
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user