Compare commits

..
13 Commits
Author SHA1 Message Date
Akizon77 2f70b44040 fix(rpc): common:getNodes returns uuid-keyed map instead of array
Each value still contains its own uuid field. Single-uuid query keeps returning a single object.
2026-07-20 20:27:24 +08:00
Akizon 5d26f1b18a Update generate-release-notes.yml 2026-07-20 09:53:15 +08:00
Akizon77 80cc39b02d Include commits in release notes 2026-07-20 09:53:15 +08:00
Akizon77 11c65ddbd0 Use stable release base for snapshot notes 2026-07-20 09:53:15 +08:00
Akizon77 ad74b57ecc Read release note workflow inputs 2026-07-20 09:53:15 +08:00
Akizon77 e13f79577d Improve release notes generation 2026-07-20 09:53:14 +08:00
Akizon77 832b80054f ci: generate AI-assisted release notes from stable tags 2026-07-20 09:53:14 +08:00
Akizon77 09d70996d3 ci: reuse frontend and toolchain actions in workflows
Extract repeated frontend build, frontend restore, and Zig setup steps into shared GitHub Actions so CI workflows use the same artifact and toolchain setup path. Update binary and Docker builds to depend on the frontend artifact, and require an explicit Docker dispatch version for controlled image tagging.
2026-07-20 09:53:14 +08:00
Akizon77 303f2bbcf5 Use UTC+8 for snapshot versions 2026-07-20 09:53:14 +08:00
airium 28ce225aa0 ci: align snapshot workflow behavior 2026-07-20 09:53:14 +08:00
Akizon77 683b7f652b fix: avoid sqlite database is locked failures for record writes 2026-07-20 09:53:05 +08:00
guocn a181b22071 fix(theme): prevent path traversal in theme operations
DeleteTheme/UpdateTheme/SetTheme used the user-supplied theme name
(short) directly in filepath.Join("./data/theme", short) before
filesystem operations (os.RemoveAll / os.Stat). Without validation,
a crafted short value like "../../" normalizes outside the theme
directory, letting an authenticated admin delete or access arbitrary
directories within the process working directory.

Reuse the existing isValidThemeShort validator (allowlist of
[a-zA-Z0-9_-]) in all three handlers before the path is built.
Add a regression test covering traversal payloads and absolute paths.
2026-07-20 09:53:05 +08:00
Torther d2f9261bc2 fix(telegram): include API error description in non-200 responses
SendTextMessage checked resp.StatusCode before decoding the JSON body, so non-200 responses returned only the status code without the error reason.

Move JSON decode before the status check. When the status is not 200, use the description field from the API response as the error message. Fall back to the original status code message if decode fails or description is empty.
2026-07-20 09:53:04 +08:00
152 changed files with 7125 additions and 11912 deletions

No files matched your search

-4
View File
@@ -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
-4
View File
@@ -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
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+5
View File
@@ -10,6 +10,11 @@ var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // 保留的兼容参数,当前未使用
DatabasePort string // 保留的兼容参数,当前未使用
DatabaseUser string // 保留的兼容参数,当前未使用
DatabasePass string // 保留的兼容参数,当前未使用
DatabaseName string // 保留的兼容参数,当前未使用
Listen string
)
-67
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+3 -3
View File
@@ -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
+20 -6
View File
@@ -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
}
+3 -2
View File
@@ -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
View File
@@ -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 {
+193
View File
@@ -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
}
*/
+83
View File
@@ -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()
}
-6
View File
@@ -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
View File
@@ -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
}
-92
View File
@@ -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
}
-37
View File
@@ -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)
}
}
}
-95
View File
@@ -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
}
-167
View File
@@ -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()
})
}
-61
View File
@@ -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
}
-999
View File
@@ -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
}
-481
View File
@@ -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)
}
}
-72
View File
@@ -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
}
-211
View File
@@ -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
}
-38
View File
@@ -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{}{}
}
-467
View File
@@ -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
})
}
-277
View File
@@ -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])
}
}
}
-312
View File
@@ -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
}
+121
View File
@@ -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
}
+2 -4
View File
@@ -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"`
}
+2 -4
View File
@@ -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
View File
@@ -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"` // 显存总量(字节)
+5 -7
View File
@@ -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 -3
View File
@@ -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 值,单位毫秒
}
+2 -4
View File
@@ -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"`
}
-77
View File
@@ -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)
}
}
-5
View File
@@ -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
View File
@@ -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
})
}
+321
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-42
View File
@@ -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
View File
@@ -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
-327
View File
@@ -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(...)
```
即使上报失败,也不影响页面功能。
+6 -2
View File
@@ -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
)
+30
View File
@@ -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=
-3
View File
@@ -226,9 +226,6 @@ detect_arch() {
riscv64)
echo "riscv64"
;;
loongarch64|loong64)
echo "loong64"
;;
*)
log_error "不支持的架构: $arch"
exit 1
+92 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()]
-35
View File
@@ -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
View File
@@ -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
}
-66
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -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")
}
-37
View File
@@ -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
View File
@@ -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:
+14 -69
View File
@@ -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)
-48
View File
@@ -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
}
-31
View File
@@ -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)
}
})
}
}
-305
View File
@@ -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
}
-182
View File
@@ -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
}
-15
View File
@@ -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),
}
}
+8 -22
View File
@@ -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.
-24
View File
@@ -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.
//
+2 -51
View File
@@ -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))
}
}
+7 -47
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+12 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-562
View File
@@ -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
}
-277
View File
@@ -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")
}
}
+15 -32
View File
@@ -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)
}
+5 -55
View File
@@ -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},
-242
View File
@@ -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, `"`, `""`) + `"`
}
-99
View File
@@ -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)
}
}
-32
View File
@@ -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))
}
-30
View File
@@ -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
View File
@@ -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
View File
@@ -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"`
}
+7 -10
View File
@@ -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: "",
}
+386
View File
@@ -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")
}
+28
View File
@@ -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")
}
}
-8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+2 -3
View File
@@ -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())
+2 -2
View File
@@ -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)
-12
View File
@@ -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 {
+1 -18
View File
@@ -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,
}
+7 -9
View File
@@ -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: "⏳",
})
+5 -5
View File
@@ -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