Compare commits

..
Author SHA1 Message Date
Akizon77 c828653d20 refactor: improve metric store lifecycle management with context-aware close and operation gate 2026-07-12 16:52:50 +08:00
Akizon77 ff358edd8b Fix metric rollup compaction and retention 2026-07-12 12:36:27 +08:00
Akizon77 3b48339209 fix(metrics): select compatible rollup intervals 2026-07-12 01:01:03 +08:00
Akizon77 42ef993cd6 feat(metricstore): add downsampling toggle, raise default retention to 90d, and cleanup expired data on compact 2026-07-11 23:48:58 +08:00
Akizon77 39c5f7bb69 Include commits in release notes 2026-07-11 22:38:09 +08:00
Akizon77 9831df0af8 feat(metric): preserve series identity in rollup queries
Add PreserveSeries support to rollup aggregation so rollup and mixed raw paths can keep entity/tag identities instead of always merging matched series. Thread series-aware rollup keys through folding and FillEmpty handling to keep output points aligned with raw aggregation behavior.
2026-07-11 22:00:49 +08:00
Akizon77 29eecf55f7 Use stable release base for snapshot notes 2026-07-11 21:49:39 +08:00
Akizon77 0660ee74d4 Read release note workflow inputs 2026-07-11 21:24:01 +08:00
Akizon77 5ade710f32 Improve release notes generation 2026-07-11 21:07:34 +08:00
Akizon77 855ff82f97 feat(metricstore): track ping packet loss in metrics
Add a ping loss gauge and write it alongside ping latency records so failed ping samples are captured as loss indicators.

Update ping deletion and aggregation paths to include loss data, keeping cleanup and public ping stats consistent across latency and packet loss metrics.
2026-07-11 20:00:01 +08:00
Akizon77 8910c2e9c7 ci: generate AI-assisted release notes from stable tags 2026-07-11 18:14:32 +08:00
Akizon77 b2fe9ab068 feat(metricstore): aggregate record queries via series rollups
Refactor record loading to query metric series with calculated downsample intervals and shared metric mapping. Add all-client time-range loading by discovering record entity IDs, and centralize record sorting to keep large range queries efficient and ordered.
2026-07-11 17:28:13 +08:00
Akizon77 8a529b4eb0 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-11 17:10:01 +08:00
Akizon77 46b4e37178 fix(metricstore): rotate compaction cursor per metric
Track compaction progress across individual metric definitions instead of treating the metric store as one operation. This preserves the rotating cursor through full cycles, resumes from the failed metric on errors, and reports how many rollup buckets were written when compaction fails.

Add a regression test covering cursor stability after a complete rotated compaction pass.
2026-07-11 16:48:10 +08:00
Akizon77 5ad791fe50 feat(metricstore): add scheduled rollup compaction 2026-07-11 16:16:38 +08:00
Akizon77 8911c08a5b fix(metric): preserve series identity during aggregation
Group aggregate buckets by metric, entity, and tag fingerprint so mixed-series inputs do not collapse into one bucket. Carry cloned tags into aggregate output and filled empty buckets to keep results deterministic and isolated by series.
2026-07-11 15:52:29 +08:00
Akizon77 7d95240bc1 feat: add metric retention policy management 2026-07-11 15:08:00 +08:00
Akizon77 7e0d542694 refactor(metrics): use metric retention for cleanup settings
Move scheduled cleanup to MetricStoreConfig.RetentionDays and apply the same cutoff to records, task results, and ping history. Expose derived legacy public settings for theme compatibility while removing record retention fields from Settings.
2026-07-10 23:38:46 +08:00
Akizon77 84130dfff5 fix: run metric store migrations before startup migration
Run one-shot metric store migrations after the metric store is initialized, before backend-switch startup migration begins.

Move legacy monitoring table cleanup out of dbcore initialization so old records are imported through the migration flow before being removed.
2026-07-09 22:55:46 +08:00
Akizon77 e0094447a6 Use UTC+8 for snapshot versions 2026-07-08 23:09:09 +08:00
Akizon77 ab31fc69fe refactor(oauth): remove Cloudflare Access provider support
Remove the Cloudflare Access provider registration and clean up any persisted CloudflareAccess OIDC config during initialization so deployments do not keep referencing the removed provider.

Add a database helper for deleting OIDC configs by name and drop now-unused OIDC/oauth2 dependencies from the module files.
2026-07-08 21:04:46 +08:00
Akizon77 73071ff1e8 refactor: remove built-in Cloudflare tunnel support
Remove cloudflared from the Docker image and server startup lifecycle so the app no longer downloads, starts, or shuts down a managed tunnel process.

Drop the Cloudflare tunnel token setting from config, migrations, and admin routing to keep configuration aligned with the removed integration.
2026-07-08 19:38:30 +08:00
Akizon77 4ad190a7f4 feat(metrics): always enable metric store with startup migration and version-based backup
Enable the metric store unconditionally, dropping the deprecated
metric_store_enabled toggle. When unconfigured it uses SQLite
(./data/metrics.db), otherwise the configured MySQL/PostgreSQL backend.
Initialization failure now aborts startup instead of silently falling
back to the legacy records table.

On startup, run a one-time migration that moves data from the previous
metrics target to the current one when the storage backend changes
(e.g. SQLite to MySQL). Migration failure also aborts startup with a
clear error. The legacy komari.db monitoring tables are fully retired.

Inject a build version ID via dbcore.SetVersionID before Initialize so
dbcore can auto-backup ./data on version upgrades. The version is now
persisted in the configs table (system_version key), replacing the
./data/.komari-version file so it travels with config backups.

Remove records.CompactRecord calls and drop obsolete report cache tests.
2026-07-08 08:37:20 +08:00
Akizon77 6ddb143365 refactor: split startup into app lifecycle stages 2026-07-07 21:44:53 +08:00
airium eb4ca83a13 ci: align snapshot workflow behavior 2026-07-07 18:12:11 +08:00
Akizon77 43c80d238b fix(migration): 恢复迁移时保留已迁移进度计数,避免从0重新累计 2026-07-01 23:28:01 +08:00
Akizon77 416ea871be feat(metrics): 支持迁移暂停/恢复,进度持久化与重启后孤儿状态修复 2026-07-01 23:06:07 +08:00
Akizon77 5c2bd4ec74 perf: 优化 metrics 迁移性能 - 批量写入 + keyset 分页 2026-07-01 22:24:21 +08:00
Akizon77 be6af33d85 merge: metrics DSN autodetect 2026-06-30 23:44:14 +08:00
Akizon77 d02d9347dc feat(metrics): infer DB driver from DSN automatically 2026-06-30 23:43:47 +08:00
Akizon77 64998b527c fix: avoid sqlite table locks during metric migration 2026-06-30 22:43:43 +08:00
Akizon77 9e5b6df196 feat(metrics): hot-reload metrics DB config with connection test before save 2026-06-30 21:28:04 +08:00
Akizon77 332053d8ec fix: avoid sqlite database is locked failures for record writes 2026-06-30 19:28:00 +08:00
Akizon b6fcdd396d Merge pull request #583 from Torther/main
fix(telegram): 非 200 响应时返回 API 错误描述而非仅状态码
2026-06-30 18:39:06 +08:00
Akizonandguocn 92ec774249 fix(theme): prevent path traversal in theme operations (#584)
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.

Co-authored-by: guocn <guocn@users.noreply.github.com>
2026-06-30 18:38:20 +08:00
guocn 404d97d846 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-06-29 23:12:42 +08:00
Akizon77 0132d1cbd8 Merge branch 'refactor/metrics-unify-storage': unify storage through metric store + getNodes map 2026-06-29 21:54:00 +08:00
Akizon77 ff6daa6ce3 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-06-29 21:52:16 +08:00
Akizon77 6a5f3ef04d refactor(metrics): route all storage through metric store when enabled
- Fix all-clients query bypassing metric store (empty uuid path)

- Write records/GPU to metric store when enabled; traffic baseline from metric store

- Fix traffic report bypassing metric store (would report zero traffic)

- Use dedicated gpu.device.usage metric to avoid collision with entity-level gpu.usage

- Remove legacy GPU tables (gpu_records/gpu_records_long_term) and related code

- Remove never-used dead code: RecordOne/RecordGPU/GetLatestRecord/GetAllRecords/SaveClientReport/SaveReport/ParseReport
2026-06-29 21:46:53 +08:00
Torther da047cf612 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-06-29 10:12:38 +08:00
Akizon77 f0877d6d32 fix(database): use FromTime() for time queries and improve cache cleanup 2026-06-28 22:28:57 +08:00
Akizon77 f2c46dbfb1 feat: route metric reads and writes to dedicated store 2026-06-28 22:08:35 +08:00
66 changed files with 7586 additions and 3075 deletions

No files matched your search

@@ -3,6 +3,11 @@ name: Generate Release Notes
on:
release:
types: [published]
repository_dispatch:
types:
- komari-web-release-published
schedule:
- cron: "41 * * * *"
workflow_dispatch:
inputs:
tag:
+1 -11
View File
@@ -8,19 +8,9 @@ ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl tzdata
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
+445
View File
@@ -0,0 +1,445 @@
package cmd
import (
"context"
"errors"
"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/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/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"
)
// cleanupFunc 是一个关闭阶段执行的清理函数。
type cleanupFunc struct {
name string
fn func(ctx context.Context) error
}
// App 显式建模服务端的启动生命周期。
//
// 过去 RunServer 把目录创建、数据库、metric store、GeoIP、定时任务、通知、OAuth、
// Nezha、Gin 中间件、路由、HTTP 启动与 shutdown 全部混在一个函数里,
// 启动顺序只能靠通读整段代码推断,异步初始化过早且吞掉错误,关闭也不完整。
//
// App 把这些拆成有序阶段:
//
// Bootstrap 基础设施:目录、数据库、默认管理员、配置快照
// InitStores 存储:metric store
// InitProviders 外部 provider:OAuth(同步,路由依赖)、GeoIP、消息发送、Nezha 兼容
// StartBackground 后台:定时任务
// BuildRouter 构建 Gin 引擎与路由
// Run 启动 HTTP 服务并阻塞直到收到信号
// Shutdown 反序执行已登记的清理函数
//
// 每个阶段返回错误即可让上层决定是否中止启动;各资源在创建时把对应清理登记到
// cleanup 栈,关闭时按后进先出(LIFO)顺序释放。
type App struct {
settings *config.Settings
engine *gin.Engine
server *http.Server
reload *ReloadManager
dbReady 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)
}
return nil
}
// InitProviders 初始化外部 provider。
//
// OAuth 必须在 HTTP 服务开始接收请求之前同步完成,否则 oauth.CurrentProvider()
// 存在空指针风险;GeoIP 与消息发送允许后台初始化;Nezha 兼容按配置决定是否启动。
func (a *App) InitProviders() error {
// OAuth:同步初始化并处理错误,保证路由可用前 provider 已就绪。
if err := oauth.Initialize(); err != nil {
// 记录但不致命:Initialize 内部在失败时已回退到 github provider。
log.Printf("Failed to initialize OAuth provider: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
}
a.addCleanup("oauth", func(context.Context) error {
return oauth.Shutdown()
})
// 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()
})
// Nezha 兼容 gRPC 服务:按配置启动,且无论初始是否启动都登记清理,
// 以覆盖运行期通过热重载开启的情况。
if a.settings.NezhaCompatEnabled {
listen := a.settings.NezhaCompatListen
go func() {
if err := nezha.StartNezhaCompat(listen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
a.addCleanup("nezha-compat", func(context.Context) error {
// 未运行时 StopNezhaCompat 返回错误,属正常情况,忽略即可。
_ = nezha.StopNezhaCompat()
return nil
})
return 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))
}
}
})
// Nezha 兼容服务开关。
a.reload.Register("nezha-compat", func(event config.ConfigEvent) {
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))
}
}
}
})
// 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("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[metricstore.MetricStoreConfig]()
retentionDays := cfg.RetentionDays
if retentionDays <= 0 {
retentionDays = 30
}
before := time.Now().Add(-24 * time.Hour * time.Duration(retentionDays))
tasks.ClearTaskResultsByTimeBefore(before)
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())
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)
}
}
func minuteScheduledWork() {
report_cache.SaveClientReportToDB()
// 每分钟检查一次流量提醒
notifier.CheckTraffic()
}
+67
View File
@@ -0,0 +1,67 @@
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)
}
+25 -206
View File
@@ -1,40 +1,9 @@
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"
)
@@ -54,184 +23,34 @@ func init() {
RootCmd.AddCommand(ServerCmd)
}
// RunServer 按显式的生命周期阶段启动服务端。
//
// 具体各阶段的职责与顺序见 App(cmd/app.go)。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
app := NewApp()
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))
}
}()
// 初始化阶段:任一步失败都不应继续对外服务。
type stage struct {
name string
fn func() error
}
stages := []stage{
{"bootstrap", app.Bootstrap},
{"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)
}
}
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 {
panic(err)
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
if err := app.Run(); err != nil {
log.Fatalf("server exited with error: %v", err)
}
}
// #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()
}
-193
View File
@@ -1,66 +1,14 @@
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
@@ -71,18 +19,6 @@ 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 {
// 防止输入不合理范围
@@ -166,132 +102,3 @@ 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
}
*/
-55
View File
@@ -1,9 +1,7 @@
package clients
import (
"sync"
"testing"
"time"
"github.com/komari-monitor/komari/utils"
"github.com/stretchr/testify/assert"
@@ -28,56 +26,3 @@ func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
})
}
}
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()
}
+267 -152
View File
@@ -168,8 +168,101 @@ 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().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"
@@ -192,160 +285,182 @@ func buildSQLiteDSN(databaseFile string) string {
return "file:" + filepath.ToSlash(databaseFile) + separator + params
}
func GetDBInstance() *gorm.DB {
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
func Initialize() error {
once.Do(func() {
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().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")
}
}
}()
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
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 {
log.Fatalf("Failed to create tables: %v", err)
}
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)
}
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)
}
// 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)")
}
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
}
// Close 关闭底层数据库连接,供关闭流程调用。
func Close() error {
if instance == nil {
return nil
}
sqlDB, err := instance.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
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().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")
}
}
}()
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
// gorm.Open 会创建空库。
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
dbFileExistedAtStartup = true
}
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
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 {
return fmt.Errorf("failed to connect to SQLite3 database: %w", 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:
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()
// 自动迁移模型
//
// 注意:负载/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
}
+92
View File
@@ -0,0 +1,92 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,95 @@
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
@@ -0,0 +1,167 @@
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()
})
}
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
package metricstore
import (
"context"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestDefaultRollupPolicy(t *testing.T) {
policy := defaultRollupPolicy(30)
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, 30 * 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:",
RetentionDays: 30,
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 TestBuildMetricConfigDefaultsRetentionToNinetyDays(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: true,
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.DefaultRetentionDays != DefaultMetricRetentionDays {
t.Fatalf(
"default retention = %d, want %d",
cfg.DefaultRetentionDays,
DefaultMetricRetentionDays,
)
}
wantRollupRetention := time.Duration(DefaultMetricRetentionDays) * 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 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(30)),
))
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(30)),
))
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: models.FromTime(ts),
Cpu: 42.5,
Ram: 123456,
RamTotal: 999999,
Disk: 456789,
DiskTotal: 777777,
Load: 0.75,
Connections: 321,
ConnectionsUdp: 12,
}
if err := WriteRecord(ctx, rec); 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
@@ -0,0 +1,72 @@
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
}
+209
View File
@@ -0,0 +1,209 @@
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,
RetentionDays: base.RetentionDays,
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)
}
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().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
@@ -0,0 +1,38 @@
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{}{}
}
+312
View File
@@ -0,0 +1,312 @@
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(),
}
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()
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
}
+5
View File
@@ -23,6 +23,11 @@ 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 {
+16 -587
View File
@@ -1,609 +1,38 @@
package records
import (
"errors"
"log"
"sort"
"strings"
"context"
"time"
"gorm.io/gorm"
"github.com/komari-monitor/komari/cmd/flags"
"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"
)
func RecordOne(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
func RecordGPU(rec models.GPURecord) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
// 历史监控数据已完全迁移到 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 {
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
return metricstore.DeleteAllRecords(context.Background())
}
// GetGPURecordsByClientAndTime 获取GPU记录数据
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
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
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
}
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
return metricstore.DeleteRecordsBefore(context.Background(), before)
}
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
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
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, 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
})
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end)
}
-321
View File
@@ -1,321 +0,0 @@
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)
}
+11 -30
View File
@@ -1,9 +1,11 @@
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"
@@ -125,34 +127,25 @@ 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 {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
return metricstore.WritePingRecord(context.Background(), record)
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
return err
return metricstore.DeletePingRecordsBefore(context.Background(), time)
}
func DeletePingRecords(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
return metricstore.DeletePingRecordsByTask(context.Background(), id)
}
func DeleteAllPingRecords() error {
db := dbcore.GetDBInstance()
result := db.Exec("DELETE FROM ping_records")
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
return metricstore.DeleteAllPingRecords(context.Background())
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
@@ -201,17 +194,5 @@ func AddDefaultOnClientUUID(uuid string) error {
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
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
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
}
+9 -13
View File
@@ -9,6 +9,7 @@ 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"
)
@@ -42,15 +43,6 @@ 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 {
@@ -61,7 +53,10 @@ func GetPublicInfo() (map[string]interface{}, error) {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
}
metricCfg, err := config.GetManyAs[metricstore.MetricStoreConfig]()
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
@@ -135,9 +130,10 @@ func GetPublicInfo() (map[string]interface{}, error) {
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"record_enabled": metricCfg.RetentionDays > 0, //兼容旧版本主题
"record_preserve_time": metricCfg.RetentionDays * 24, //兼容旧版本主题
"ping_record_preserve_time": metricCfg.RetentionDays * 24, //兼容旧版本主题
"metric_retention_days": metricCfg.RetentionDays,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
-3
View File
@@ -3,7 +3,6 @@ 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
@@ -42,7 +41,6 @@ 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
@@ -69,7 +67,6 @@ 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
-6
View File
@@ -12,8 +12,6 @@ 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=
@@ -31,8 +29,6 @@ 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=
@@ -155,8 +151,6 @@ 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=
+29 -36
View File
@@ -25,13 +25,13 @@ type Settings struct {
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"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token" default:""`
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
// 自定义美化
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"`
@@ -40,38 +40,34 @@ 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%
// 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
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"
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"
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"
CustomHeadKey = "custom_head"
CustomBodyKey = "custom_body"
NotificationEnabledKey = "notification_enabled"
NotificationMethodKey = "notification_method"
NotificationTemplateKey = "notification_template"
@@ -79,9 +75,6 @@ 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"
)
+107 -13
View File
@@ -7,6 +7,17 @@ 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 在内存中按查询配置将原始点分桶并聚合。
@@ -22,42 +33,124 @@ func AggregatePoints(points []Point, query AggregateQuery) ([]AggregatePoint, er
}
points = sortedPoints(points)
groups := make(map[int64][]Point)
groups := make(map[aggregateGroupKey][]Point)
seriesTags := make(map[aggregateSeriesKey]map[string]string)
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()
groups[bucket] = append(groups[bucket], point)
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)
}
}
var buckets []int64
var keys []aggregateGroupKey
if query.FillEmpty {
seriesKeys := sortedAggregateSeriesKeys(seriesTags)
if len(seriesKeys) == 0 {
tagsHash, _, err := tagsFingerprint(query.Tags)
if err != nil {
return nil, err
}
series := aggregateSeriesKey{
metricName: query.MetricName,
entityID: query.EntityID,
tagsHash: tagsHash,
}
seriesKeys = append(seriesKeys, series)
seriesTags[series] = cloneStringMap(query.Tags)
}
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
buckets = append(buckets, t.UnixNano())
bucket := t.UnixNano()
for _, series := range seriesKeys {
keys = append(keys, aggregateGroupKey{aggregateSeriesKey: series, bucket: bucket})
}
}
} else {
for bucket := range groups {
buckets = append(buckets, bucket)
for key := range groups {
keys = append(keys, key)
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i] < buckets[j] })
sortAggregateGroupKeys(keys)
}
out := make([]AggregatePoint, 0, len(buckets))
for _, bucket := range buckets {
group := groups[bucket]
out := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
group := groups[key]
value, err := aggregateValue(group, query.Aggregation)
if err != nil {
return nil, err
}
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, bucket).UTC(),
MetricName: key.metricName,
EntityID: key.entityID,
Bucket: time.Unix(0, key.bucket).UTC(),
Value: value,
Count: len(group),
Tags: cloneStringMap(seriesTags[key.aggregateSeriesKey]),
})
}
return out, nil
}
func sortedAggregateSeriesKeys(tags map[aggregateSeriesKey]map[string]string) []aggregateSeriesKey {
keys := make([]aggregateSeriesKey, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
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
})
return keys
}
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 基于一组点计算统计摘要,包括均值、百分位、首尾值和标准差。
@@ -264,6 +357,7 @@ func emptyBuckets(query AggregateQuery) []AggregatePoint {
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: t,
Tags: cloneStringMap(query.Tags),
})
}
return out
+55
View File
@@ -485,6 +485,61 @@ 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}); 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 验证原始点分页参数不会影响聚合输入。
+295
View File
@@ -0,0 +1,295 @@
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)
switch driver {
case DriverMySQL:
return `SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (?, ?, ?)`, stringsToAny(names), nil
case DriverPostgreSQL:
for i := range names {
names[i] = strings.ToLower(names[i])
}
return `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 ($1, $2, $3)`, 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 {
return []string{t.definitions, t.points, t.rollups}
}
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
}
+181
View File
@@ -0,0 +1,181 @@
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",
}
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`",
sizeParts: []string{"information_schema.TABLES", "TABLE_SCHEMA = DATABASE()", "TABLE_NAME IN (?, ?, ?)"},
sizeArgs: []any{"Metric_definitions", "Metric_points", "Metric_rollups"},
hasSizeSQL: true,
},
{
name: "postgresql",
driver: DriverPostgreSQL,
action: MaintenanceVacuumFull,
reclaim: `VACUUM (FULL, ANALYZE) "metric_definitions", "metric_points", "metric_rollups"`,
sizeParts: []string{"pg_total_relation_size(c.oid)", "n.nspname = current_schema()", "c.relname IN ($1, $2, $3)"},
sizeArgs: []any{"metric_definitions", "metric_points", "metric_rollups"},
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
}
+14
View File
@@ -166,6 +166,20 @@ 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.
+17
View File
@@ -89,6 +89,23 @@ func (p RollupPolicy) rawCutoff(now time.Time) time.Time {
return time.Unix(0, floorDivNano(cutoff.UnixNano(), interval)).UTC()
}
// withMetricRetention applies a metric definition as an upper bound on every
// rollup tier while preserving the store policy's configured maxima.
func (p RollupPolicy) withMetricRetention(retention time.Duration) RollupPolicy {
if retention <= 0 || len(p.Tiers) == 0 {
return p
}
out := p
out.Tiers = append([]RollupTier(nil), p.Tiers...)
for i := range out.Tiers {
if out.Tiers[i].Retention > retention {
out.Tiers[i].Retention = retention
}
}
return out
}
// Validate enforces the structural rules that make cascading composition and
// retention well-defined.
//
+40
View File
@@ -345,3 +345,43 @@ 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}); 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)
}
}
+264 -75
View File
@@ -17,9 +17,8 @@ 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 (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.
// points. By default, matched series are merged into each output bucket; callers
// that need entity/tag identities preserved can set PreserveSeries.
//
// Every aggregation works except AggRate, which needs the ordered raw series and
// is therefore raw-only. Percentiles (p50, p95, p99, and arbitrary pXX) are
@@ -32,9 +31,8 @@ import (
// rollup 桶组成。
//
// query.Tags 会被遵守:只有存储标签集合匹配过滤条件的 rollup 序列会被合入,
// 因此标签过滤会选中与原始点查询相同的数据(每个不同标签组合都是自己的
// rollup 序列)。没有标签过滤时,同一桶内的所有标签序列会合并在一起,
// 与原始路径的跨标签聚合一致。
// 因此标签过滤会选中与原始点查询相同的数据。默认会把匹配序列合并进每个
// 输出桶;需要保留 entity/tag 身份的调用方可以设置 PreserveSeries。
//
// 除 AggRate 外,每种聚合都可用;AggRate 需要有序原始序列,因此只能基于原始点。
// 百分位(p50、p95、p99 和任意 pXX)通过合并每桶 t-digest 回答,因此能在
@@ -72,7 +70,7 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
if err != nil {
return nil, err
}
groups := foldRollupRows(nil, rows, query.Interval, comp)
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries)
out, err := rollupGroupsToPoints(groups, query)
if err != nil {
@@ -89,27 +87,103 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
// rollupGroupsToPoints 将合并后的输出桶转换为有序 AggregatePoint,并根据
// 每个桶的摘要或 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())
}
} else {
for k := range groups {
bucketStarts = append(bucketStarts, k)
}
sort.Slice(bucketStarts, func(i, j int) bool { return bucketStarts[i] < bucketStarts[j] })
func rollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
if !query.PreserveSeries {
return mergedRollupGroupsToPoints(groups, query)
}
out := make([]AggregatePoint, 0, len(bucketStarts))
for _, start := range bucketStarts {
b := groups[start]
var keys []rollupKey
seriesTags := make(map[rollupSeriesKey]string)
if query.FillEmpty {
series := rollupSeriesKeys(groups)
if len(series) == 0 {
tagsHash, tagsJSON, err := tagsFingerprint(query.Tags)
if err != nil {
return nil, err
}
series = append(series, rollupSeriesKey{entityID: query.EntityID, tagsHash: tagsHash})
seriesTags[series[0]] = tagsJSON
} else {
for _, key := range series {
seriesTags[key] = rollupSeriesTagsJSON(groups, key)
}
}
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
bucket := t.UnixNano()
for _, seriesKey := range series {
keys = append(keys, rollupKey{
entityID: seriesKey.entityID,
tagsHash: seriesKey.tagsHash,
bucket: bucket,
})
}
}
} else {
for key := range groups {
keys = append(keys, key)
}
sortRollupKeys(keys)
}
out := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
b := groups[key]
if b == nil {
tags, err := rollupTagsFromJSON(seriesTags[rollupSeriesKey{entityID: key.entityID, tagsHash: key.tagsHash}])
if err != nil {
return nil, err
}
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: key.entityID,
Bucket: time.Unix(0, key.bucket).UTC(),
Tags: tags,
})
continue
}
v, ok := b.value(query.Aggregation)
if !ok {
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
}
tags, err := rollupTagsFromJSON(b.tagsJSON)
if err != nil {
return nil, err
}
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,
})
}
return out, nil
}
func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
var keys []rollupKey
if query.FillEmpty {
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
keys = append(keys, rollupKey{bucket: t.UnixNano()})
}
} else {
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]
if b == nil {
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, start).UTC(),
Bucket: time.Unix(0, key.bucket).UTC(),
Tags: cloneStringMap(tags),
})
continue
}
@@ -120,9 +194,10 @@ func rollupGroupsToPoints(groups map[int64]*rollupBucket, query AggregateQuery)
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, start).UTC(),
Bucket: time.Unix(0, key.bucket).UTC(),
Value: v,
Count: int(b.count),
Tags: cloneStringMap(tags),
})
}
return out, nil
@@ -214,17 +289,20 @@ func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶,按桶合并摘要
// (包括 t-digest)。groups 可为 nil,此时会分配新 map;传入已有 map 可让调用方
// 把 rollup 和 raw 的贡献累加到同一批输出桶中。
func foldRollupRows(groups map[int64]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64) map[int64]*rollupBucket {
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64, preserveSeries bool) map[rollupKey]*rollupBucket {
if groups == nil {
groups = make(map[int64]*rollupBucket)
groups = make(map[rollupKey]*rollupBucket)
}
size := interval.Nanoseconds()
for _, r := range rows {
bkt := floorDivNano(r.bucket, size)
b := groups[bkt]
key := foldedRollupKey(r.entityID, r.bucketData.tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucket(comp)
groups[bkt] = b
b.tagsHash = r.bucketData.tagsHash
b.tagsJSON = r.bucketData.tagsJSON
groups[key] = b
}
b.mergeStored(r.bucketData)
}
@@ -240,22 +318,91 @@ func foldRollupRows(groups map[int64]*rollupBucket, rows []storedRollup, interva
// foldRawPoints 将原始点折叠进 interval 宽的输出桶,把每个观测值加入对应桶的
// 累加器。它与 foldRollupRows 共用桶 map,因此混合查询可以把旧 rollup 半边和
// 近期 raw 半边合并到同一批输出桶中并一起聚合(跨边界的 count/avg/百分位正确)。
func foldRawPoints(groups map[int64]*rollupBucket, points []Point, interval time.Duration, comp float64) map[int64]*rollupBucket {
func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval time.Duration, comp float64, preserveSeries bool) (map[rollupKey]*rollupBucket, error) {
if groups == nil {
groups = make(map[int64]*rollupBucket)
groups = make(map[rollupKey]*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)
b := groups[bkt]
key := foldedRollupKey(p.EntityID, tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucket(comp)
groups[bkt] = b
b.tagsHash = tagsHash
b.tagsJSON = tagsJSON
groups[key] = b
}
b.addPoint(p.Value, ts)
}
return groups
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}
}
type rollupSeriesKey struct {
entityID string
tagsHash string
}
func rollupSeriesKeys(groups map[rollupKey]*rollupBucket) []rollupSeriesKey {
seen := make(map[rollupSeriesKey]struct{})
keys := make([]rollupSeriesKey, 0)
for key := range groups {
series := rollupSeriesKey{entityID: key.entityID, tagsHash: key.tagsHash}
if _, ok := seen[series]; ok {
continue
}
seen[series] = struct{}{}
keys = append(keys, series)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].entityID != keys[j].entityID {
return keys[i].entityID < keys[j].entityID
}
return keys[i].tagsHash < keys[j].tagsHash
})
return keys
}
func rollupSeriesTagsJSON(groups map[rollupKey]*rollupBucket, series rollupSeriesKey) string {
for key, bucket := range groups {
if key.entityID == series.entityID && key.tagsHash == series.tagsHash && bucket != nil {
return bucket.tagsJSON
}
}
return "{}"
}
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
}
// scanStoredRollups reconstructs storedRollup rows (including tag identity and
@@ -332,6 +479,68 @@ 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`:
//
@@ -343,8 +552,9 @@ func rawTagsToJSON(v any) (string, error) {
// - 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 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.
// - 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.
//
// 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
@@ -360,8 +570,9 @@ func rawTagsToJSON(v any) (string, error) {
// query.Interval,且 (b) 保留时间能覆盖到窗口起点,然后从该层服务查询。
// - 如果查询跨越原始数据保留期边界,它使用混合方式:读取旧部分的 rollup
// 和最近部分的原始点,然后合并结果,避免丢掉未 compact 的最近数据。
// - 如果没有层级符合条件,它会回退到原始点(对于已经过期的数据可能不完整),
// 让调用仍返回当前能给出的最佳答案。
// - 如果起点早于所有层级的保留窗口,它会读取保留时间最长的兼容层级,返回请求
// 窗口与实际保留数据的交集。
// - 只有没有任何层级与输出间隔兼容时才回退到原始点。
//
// query.Tags 在两条分支上都会被遵守:原始路径已按标签过滤,rollup 路径会按
// 存储的标签集合过滤,因此无论由哪个数据源回答,标签过滤都会选择相同序列。
@@ -393,29 +604,9 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
return s.Aggregate(ctx, query)
}
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 now.Add(-tier.Retention).After(q.Start) {
continue
}
return s.seriesHybrid(ctx, query, rawCutoff, &tier)
}
}
// 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
servicingTier := bestRollupTier(policy, query.Interval, q.Start, now)
if q.Start.Before(rawCutoff) && !q.End.Before(rawCutoff) && servicingTier != nil {
return s.seriesHybrid(ctx, query, rawCutoff, servicingTier)
}
if servicingTier == nil {
@@ -438,9 +629,10 @@ 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.
//
// 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.
// 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.
//
// seriesHybrid 回答跨越原始保留期边界的查询:读取旧部分的 rollup 和近期部分的
// 原始点,并在归约前把两者折叠进同一批 query.Interval 宽的输出桶。
@@ -451,8 +643,8 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
// 结果却只剩 raw 样本,count/avg 错误)。把两半折叠进同一个 rollupBucket 即可修复:
// count、sum/avg、min/max 和百分位都在两半的并集上计算。
//
// 为避免重复计数或留空,调用方传入的边界已经对齐到 rollup 分辨率桶。rollup 半边
// 提供严格早于该边界的完整桶,raw 半边提供该边界及之后的点。
// 包含 raw 截止点的 rollup 桶可能尚未完整;它只包含截止点之前已压缩的数据。读取
// 该桶,再合入截止点及之后的 raw 点,可以在粗粒度层级上避免重叠和缺口。
func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary time.Time, tier *RollupTier) ([]AggregatePoint, error) {
q := query.Query.normalized()
comp := s.cfg.RollupPolicy.compression()
@@ -461,22 +653,17 @@ func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary
endNano := q.End.UnixNano()
splitAt := boundary.UTC().UnixNano()
groups := make(map[int64]*rollupBucket)
groups := make(map[rollupKey]*rollupBucket)
// 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
}
// Old half: include the rollup bucket containing splitAt. That bucket only
// contains compacted points before splitAt; the raw half starts at splitAt.
upperBucket := floorDivNano(splitAt, resNano)
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)
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries)
}
// Recent half: raw points in [splitAt, End]. Reuse the raw Query path so the
@@ -492,7 +679,9 @@ func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary
if err != nil {
return nil, err
}
foldRawPoints(groups, points, query.Interval, comp)
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries); err != nil {
return nil, err
}
}
out, err := rollupGroupsToPoints(groups, query)
+154 -32
View File
@@ -3,23 +3,24 @@ package metric
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// 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.
// 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.
//
// Returns the number of rollup buckets written across all tiers and metrics.
//
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会重建截至 `now` 的
// rollup 层,并执行每个保留窗口(原始点和各层级)。这是显式维护入口,
// 应从 cron 任务或调度器调用。它可以重复安全调用,未变化窗口保持幂等。
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会把 rollup 层推进到
// `now`,并执行每个保留窗口(原始点和各层级)。原始点保留期有限时,只会级联
// 本轮新到期及迟到的原始点。它可以重复安全调用,未变化窗口保持幂等。
//
// 返回所有指标、所有层级中写入的 rollup 桶数量。
func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
@@ -44,10 +45,10 @@ func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
return total, nil
}
// 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).
// 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.
//
// The entire compaction is performed within a SERIALIZABLE transaction to
// guarantee that the raw scan and the raw deletion observe the same snapshot.
@@ -65,9 +66,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 会压缩单个指标。它先由原始点构建最细层,再由下一层之下的
// 层级逐层合成更粗层,upsert 生成的桶,最后删除每个保留窗口中过期的数据
// (先删除原始点,再删除各层级)。
// CompactMetric 会压缩单个指标。原始点保留期有限时,它把本轮新到期的原始点
// 作为增量逐层传播;原始点永久保留时则回退到全量重建。最后删除各保留窗口中
// 已过期的数据。
//
// 整个 compaction 在一个 SERIALIZABLE 事务内执行,以保证 raw 扫描和 raw 删除
// 看到同一个快照。否则在 PostgreSQL/MySQL 的默认隔离级别下,扫描之后、删除
@@ -84,6 +85,18 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
if !policy.Enabled() {
return 0, nil
}
def, err := s.GetMetric(ctx, metricName)
if errors.Is(err, ErrNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
retentionDays := def.RetentionDays
if retentionDays <= 0 {
retentionDays = s.cfg.DefaultRetentionDays
}
policy = policy.withMetricRetention(time.Duration(retentionDays) * 24 * time.Hour)
now = now.UTC()
// Retry the whole compaction on a transient serialization/deadlock failure.
@@ -93,7 +106,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)
written, err := s.compactMetricOnce(ctx, metricName, now, policy)
if err == nil {
return written, nil
}
@@ -108,7 +121,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) (int, error) {
func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now time.Time, policy RollupPolicy) (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
@@ -119,7 +132,7 @@ func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now ti
}
defer func() { _ = tx.Rollback() }()
written, err := s.compactMetricWithinTx(ctx, metricName, now, tx)
written, err := s.compactMetricWithinTx(ctx, metricName, now, policy, tx)
if err != nil {
return written, err
}
@@ -172,11 +185,49 @@ 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, tx *sql.Tx) (int, error) {
policy := s.cfg.RollupPolicy
func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
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
@@ -234,24 +285,31 @@ func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, no
prevDelta = currentDelta
}
// 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 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 {
if policy.RawRetention > 0 {
cutoff := policy.rawCutoff(now)
if _, err := s.DeleteBeforeTx(ctx, metricName, cutoff, tx); err != nil {
return written, err
if _, err := s.DeleteBeforeTx(ctx, metricName, policy.rawCutoff(now), tx); err != nil {
return err
}
}
for i, tier := range policy.Tiers {
cutoff := now.Add(-tier.Retention)
alignment := tier.Interval
if i+1 < len(policy.Tiers) {
cutoff = alignRollupRetentionCutoff(cutoff, policy.Tiers[i+1].Interval)
alignment = 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 written, err
return err
}
}
return written, nil
return nil
}
func alignRollupRetentionCutoff(cutoff time.Time, nextInterval time.Duration) time.Time {
@@ -288,13 +346,25 @@ 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 metric_name = %s ORDER BY ts_nano ASC`,
s.tables.points, s.dialect.placeholder(1),
`SELECT entity_id, ts_nano, value, tags FROM %s WHERE %s ORDER BY ts_nano ASC`,
s.tables.points, where,
)
rows, err := q.QueryContext(ctx, sqlText, metricName)
rows, err := q.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
@@ -421,6 +491,58 @@ 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) {
if len(buckets) == 0 {
return 0, nil
}
keys := make([]rollupKey, 0, len(buckets))
for key := range buckets {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].entityID != keys[j].entityID {
return keys[i].entityID < keys[j].entityID
}
if keys[i].tagsHash != keys[j].tagsHash {
return keys[i].tagsHash < keys[j].tagsHash
}
return keys[i].bucket < keys[j].bucket
})
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
}
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 0, err
}
}
return len(keys), nil
}
// writeRollupBuckets upserts a set of computed buckets for one resolution.
//
// writeRollupBuckets 将某个分辨率下计算出的 rollup 桶批量 upsert 到数据库。
+81 -7
View File
@@ -63,6 +63,9 @@ 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 {
@@ -71,19 +74,90 @@ 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 series merge (count 20, avg of all 20 values).
// No tag filter => both tag series are returned independently, matching
// raw Aggregate and preserving the tag dimension for public query callers.
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,
Query: Query{MetricName: "util", EntityID: "host-1", Start: base, End: base.Add(10 * time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
PreserveSeries: true,
}, time.Minute)
if err != nil {
t.Fatalf("rollup all: %v", err)
}
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)
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)
}
}
func TestSeriesRollupFillEmptyPreservesTags(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: time.Hour,
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "ping.loss", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
old := now.Add(-48 * time.Hour)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "ping.loss", EntityID: "n1", Timestamp: old, Value: 0, Tags: map[string]string{"task_id": "7"}},
{MetricName: "ping.loss", EntityID: "n1", Timestamp: old, Value: 1, Tags: map[string]string{"task_id": "8"}},
}); 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: "ping.loss",
EntityID: "n1",
Start: old,
End: old.Add(2 * time.Minute),
},
Aggregation: AggAvg,
Interval: time.Minute,
FillEmpty: true,
PreserveSeries: true,
}, now)
if err != nil {
t.Fatalf("series: %v", err)
}
countByTask := map[string]int{}
emptyByTask := map[string]int{}
for _, point := range got {
taskID := point.Tags["task_id"]
if taskID == "" {
t.Fatalf("rollup fill-empty point lost task_id tag: %#v", point)
}
countByTask[taskID]++
if point.Count == 0 {
emptyByTask[taskID]++
}
}
if countByTask["7"] != 3 || countByTask["8"] != 3 {
t.Fatalf("expected 3 buckets per task, got counts=%#v points=%#v", countByTask, got)
}
if emptyByTask["7"] != 2 || emptyByTask["8"] != 2 {
t.Fatalf("expected empty buckets to retain tags, got empty=%#v points=%#v", emptyByTask, got)
}
}
+193
View File
@@ -457,6 +457,84 @@ 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.
//
@@ -674,3 +752,118 @@ 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}); 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: 90}); 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(-70 * 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(-80 * 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)
}
}
+74 -4
View File
@@ -50,6 +50,11 @@ type Store struct {
//
// tables 保存当前 Store 的实际表名。
tables tables
// maintenanceMu serializes physical storage maintenance while allowing
// concurrent size reads.
//
// maintenanceMu 串行化物理存储维护,同时允许并发读取存储大小。
maintenanceMu sync.RWMutex
// mu protects closed state.
//
// mu 保护 closed 状态。
@@ -757,6 +762,63 @@ 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 查询某指标和实体的最新采样点。
@@ -881,7 +943,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, %s AS agg_value, COUNT(*) AS agg_count FROM %s WHERE %s GROUP BY bucket ORDER BY bucket ASC`,
`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`,
bucketExpr, valueExpr, s.tables.points, where,
)
// Page over aggregate buckets (BucketLimit/BucketOffset), not raw points.
@@ -903,17 +965,25 @@ 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, &value, &count); err != nil {
if err := rows.Scan(&bucket, &metricName, &entityID, &tagsHash, &rawTags, &value, &count); err != nil {
return nil, err
}
_ = tagsHash
tags, err := decodeMap(rawTags)
if err != nil {
return nil, err
}
out = append(out, AggregatePoint{
MetricName: q.MetricName,
EntityID: q.EntityID,
MetricName: metricName,
EntityID: entityID,
Bucket: time.Unix(0, bucket).UTC(),
Value: value,
Count: count,
Tags: tags,
})
}
return out, rows.Err()
+13 -2
View File
@@ -143,9 +143,9 @@ type Definition struct {
//
// Unit 表示数值单位,例如 bytes 或 percent。
Unit string `json:"unit,omitempty"`
// RetentionDays controls raw-point retention for this metric.
// RetentionDays controls historical data retention for this metric.
//
// RetentionDays 控制该指标原始点的保留天数。
// RetentionDays 控制该指标历史数据的保留天数。
RetentionDays int `json:"retention_days,omitempty"`
// Metadata stores caller-defined metric metadata.
//
@@ -349,6 +349,13 @@ type AggregateQuery struct {
//
// FillEmpty 会为空时间段输出零计数桶。
FillEmpty bool `json:"fill_empty,omitempty"`
// 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.
//
// PreserveSeries 在基于 rollup 的读取中保留 entity/tag 维度为独立聚合序列。
// 默认值保留历史 rollup 行为:把匹配到的序列合并进同一个输出桶。
PreserveSeries bool `json:"preserve_series,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
@@ -416,6 +423,10 @@ 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.
+348
View File
@@ -0,0 +1,348 @@
package migrations
import (
"context"
"fmt"
"log"
"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
}
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
}
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)
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) (legacyMonitoringStats, error) {
var stats legacyMonitoringStats
for _, table := range []string{"records", "records_long_term"} {
n, err := migrateLegacyRecordTable(ctx, s, db, table)
if err != nil {
return stats, err
}
stats.Records += n
}
n, err := migrateLegacyGPURecordTable(ctx, s, db, "gpu_records")
if err != nil {
return stats, err
}
stats.GPU += n
n, err = migrateLegacyPingRecordTable(ctx, s, db, "ping_records")
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) (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))
}
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) (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))
}
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) (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))
}
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
return migrated, nil
}
func recordToPoints(rec models.Record) []metric.Point {
ts := rec.Time.ToTime()
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.ToTime()
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.ToTime()
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
}
+131
View File
@@ -0,0 +1,131 @@
package migrations
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
"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: models.FromTime(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: models.FromTime(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: models.FromTime(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: models.FromTime(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: models.FromTime(base.Add(30 * time.Second)), Value: -1}).Error; err != nil {
t.Fatalf("seed loss ping_records: %v", err)
}
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
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)
}
}
-7
View File
@@ -41,9 +41,6 @@ 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"`
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
}
@@ -71,7 +68,6 @@ type legacyConfig struct {
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"`
@@ -81,9 +77,6 @@ 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"`
}
-3
View File
@@ -169,9 +169,6 @@ 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 {
-386
View File
@@ -1,386 +0,0 @@
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
@@ -1,28 +0,0 @@
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,6 +115,14 @@ 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()
+12
View File
@@ -27,6 +27,18 @@ 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() {
+36 -96
View File
@@ -1,21 +1,22 @@
package notifier
import (
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
"context"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"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/corn"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/messageSender"
"gorm.io/gorm"
)
// InitTrafficReportSchedule 注册三个定时任务:日报、周报、月报
@@ -170,117 +171,55 @@ func sendTrafficReport(daily, weekly, monthly bool) {
}
}
// getClientTrafficInRange 查询某客户端在指定时间段内的流量增量
// 通过累加持久化的精确流量增量字段计算用量
// getClientTrafficInRange 查询某客户端在指定时间段内的流量增量。
//
// 历史监控数据已完全迁移到 metric store,这里从 metric store 读取区间内记录并
// 累加精确的流量增量字段计算用量;缺失增量时回退到累计流量差值。
func getClientTrafficInRange(clientUUID string, trafficType string, start, end time.Time) (int64, error) {
return getClientTrafficInRangeWithDB(dbcore.GetDBInstance(), clientUUID, trafficType, start, end)
}
type trafficDeltaRecord struct {
Time models.LocalTime `gorm:"column:time"`
NetTotalUp int64 `gorm:"column:net_total_up"`
NetTotalDown int64 `gorm:"column:net_total_down"`
TrafficUp int64 `gorm:"column:traffic_up"`
TrafficDown int64 `gorm:"column:traffic_down"`
}
func getClientTrafficInRangeWithDB(db *gorm.DB, clientUUID string, trafficType string, start, end time.Time) (int64, error) {
var recentRecords []trafficDeltaRecord
if err := db.Table("records").
Select("time, net_total_up, net_total_down, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, models.FromTime(start), models.FromTime(end)).
Find(&recentRecords).Error; err != nil {
ctx := context.Background()
recs, err := metricstore.GetRecordsByClientAndTime(ctx, clientUUID, start, end)
if err != nil {
return 0, err
}
var longTermRecords []trafficDeltaRecord
if err := db.Table("records_long_term").
Select("time, net_total_up, net_total_down, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, models.FromTime(start), models.FromTime(end)).
Find(&longTermRecords).Error; err != nil {
return 0, err
records := make([]trafficDeltaRecord, 0, len(recs))
for _, r := range recs {
records = append(records, trafficDeltaRecord{
Time: r.Time,
NetTotalUp: r.NetTotalUp,
NetTotalDown: r.NetTotalDown,
TrafficUp: r.TrafficUp,
TrafficDown: r.TrafficDown,
})
}
records := mergeTrafficRecords(recentRecords, longTermRecords)
sort.Slice(records, func(i, j int) bool {
return records[i].Time.ToTime().Before(records[j].Time.ToTime())
})
previous, err := getPreviousTrafficDeltaRecord(db, clientUUID, start)
// 计算增量基线(区间开始前最后一条累计流量)
var previous *trafficDeltaRecord
baseline, err := metricstore.GetLatestTrafficBefore(ctx, []string{clientUUID}, start)
if err != nil {
return 0, err
}
if base, ok := baseline[clientUUID]; ok {
previous = &trafficDeltaRecord{
Time: base.Time,
NetTotalUp: base.NetTotalUp,
NetTotalDown: base.NetTotalDown,
}
}
totalUp, totalDown := sumTrafficDeltas(records, previous)
return computeUsedByType(strings.ToLower(trafficType), totalUp, totalDown), nil
}
func mergeTrafficRecords(recentRecords, longTermRecords []trafficDeltaRecord) []trafficDeltaRecord {
rawSlots := make(map[time.Time]struct{}, len(recentRecords))
for _, record := range recentRecords {
rawSlots[record.Time.ToTime().Truncate(15*time.Minute)] = struct{}{}
}
longTermSlots := make(map[time.Time]struct{}, len(longTermRecords))
records := make([]trafficDeltaRecord, 0, len(longTermRecords)+len(recentRecords))
for _, record := range longTermRecords {
slot := record.Time.ToTime().Truncate(15 * time.Minute)
if _, hasRawSlot := rawSlots[slot]; hasRawSlot && record.TrafficUp == 0 && record.TrafficDown == 0 {
continue
}
longTermSlots[slot] = struct{}{}
records = append(records, record)
}
for _, record := range recentRecords {
slot := record.Time.ToTime().Truncate(15 * time.Minute)
if _, exists := longTermSlots[slot]; exists {
continue
}
records = append(records, record)
}
return records
}
func getPreviousTrafficDeltaRecord(db *gorm.DB, clientUUID string, before time.Time) (*trafficDeltaRecord, error) {
record, err := latestTrafficDeltaRecordBefore(db.Table("records"), clientUUID, before)
if err != nil {
return nil, err
}
longTermRecord, err := latestTrafficDeltaRecordBefore(db.Table("records_long_term"), clientUUID, before)
if err != nil {
return nil, err
}
if record == nil {
return longTermRecord, nil
}
if longTermRecord == nil {
return record, nil
}
if longTermRecord.Time.ToTime().After(record.Time.ToTime()) {
return longTermRecord, nil
}
return record, nil
}
func latestTrafficDeltaRecordBefore(query *gorm.DB, clientUUID string, before time.Time) (*trafficDeltaRecord, error) {
var record trafficDeltaRecord
err := query.
Select("time, net_total_up, net_total_down, traffic_up, traffic_down").
Where("client = ? AND time < ?", clientUUID, models.FromTime(before)).
Order("time DESC").
First(&record).Error
if err == nil {
return &record, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
type trafficDeltaRecord struct {
Time models.LocalTime
NetTotalUp int64
NetTotalDown int64
TrafficUp int64
TrafficDown int64
}
func sumTrafficDeltas(records []trafficDeltaRecord, previous *trafficDeltaRecord) (int64, int64) {
@@ -290,6 +229,7 @@ func sumTrafficDeltas(records []trafficDeltaRecord, previous *trafficDeltaRecord
for i := range records {
up := records[i].TrafficUp
down := records[i].TrafficDown
if previous != nil {
up = trafficDeltaOrFallback(up, records[i].NetTotalUp, previous.NetTotalUp)
down = trafficDeltaOrFallback(down, records[i].NetTotalDown, previous.NetTotalDown)
+42 -162
View File
@@ -2,186 +2,66 @@ package notifier
import (
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestGetClientTrafficInRangeAvoidsOverlappingRawAndLongTermRows(t *testing.T) {
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{}))
clientUUID := "client-overlap"
start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
sharedSlot := start.Add(15 * time.Minute)
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(sharedSlot),
TrafficUp: 100,
TrafficDown: 200,
}).Error)
rawRecords := []models.Record{
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(1 * time.Minute)), TrafficUp: 40, TrafficDown: 80},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(5 * time.Minute)), TrafficUp: 60, TrafficDown: 120},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(16 * time.Minute)), TrafficUp: 30, TrafficDown: 50},
}
for _, record := range rawRecords {
assert.NoError(t, db.Create(&record).Error)
func TestSumTrafficDeltasUsesStoredDeltas(t *testing.T) {
records := []trafficDeltaRecord{
{TrafficUp: 30, TrafficDown: 40, NetTotalUp: 130, NetTotalDown: 240},
{TrafficUp: 25, TrafficDown: 35, NetTotalUp: 155, NetTotalDown: 275},
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, sharedSlot.Add(30*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(380), used)
up, down := sumTrafficDeltas(records, nil)
assert.Equal(t, int64(55), up)
assert.Equal(t, int64(75), down)
}
func TestGetClientTrafficInRangeNormalizesLongTermSlotForOverlap(t *testing.T) {
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{}))
clientUUID := "client-overlap-normalized"
start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
sharedSlot := start.Add(15 * time.Minute)
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(sharedSlot.Add(8 * time.Minute)),
TrafficUp: 100,
TrafficDown: 200,
}).Error)
rawRecords := []models.Record{
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(1 * time.Minute)), TrafficUp: 40, TrafficDown: 80},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(5 * time.Minute)), TrafficUp: 60, TrafficDown: 120},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(16 * time.Minute)), TrafficUp: 30, TrafficDown: 50},
}
for _, record := range rawRecords {
assert.NoError(t, db.Create(&record).Error)
func TestSumTrafficDeltasFallsBackToCumulativeTotals(t *testing.T) {
previous := &trafficDeltaRecord{NetTotalUp: 100, NetTotalDown: 200}
records := []trafficDeltaRecord{
{TrafficUp: 0, TrafficDown: 0, NetTotalUp: 130, NetTotalDown: 250},
{TrafficUp: 0, TrafficDown: 0, NetTotalUp: 160, NetTotalDown: 310},
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, sharedSlot.Add(30*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(380), used)
up, down := sumTrafficDeltas(records, previous)
assert.Equal(t, int64(60), up)
assert.Equal(t, int64(110), down)
}
func TestGetClientTrafficInRangeSumsPersistedDeltasAcrossCounterReset(t *testing.T) {
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{}))
clientUUID := "client-reset"
start := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
records := []models.Record{
{Client: clientUUID, Time: models.FromTime(start.Add(0 * time.Minute)), NetTotalUp: 100, NetTotalDown: 200, TrafficUp: 0, TrafficDown: 0},
{Client: clientUUID, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 150, NetTotalDown: 260, TrafficUp: 50, TrafficDown: 60},
{Client: clientUUID, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 30, TrafficUp: 10, TrafficDown: 30},
{Client: clientUUID, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 40, TrafficUp: 15, TrafficDown: 10},
}
for _, record := range records {
assert.NoError(t, db.Create(&record).Error)
func TestSumTrafficDeltasHandlesCounterResetInFallback(t *testing.T) {
previous := &trafficDeltaRecord{NetTotalUp: 400, NetTotalDown: 550}
records := []trafficDeltaRecord{
{TrafficUp: 0, TrafficDown: 0, NetTotalUp: 500, NetTotalDown: 600},
{TrafficUp: 0, TrafficDown: 0, NetTotalUp: 100, NetTotalDown: 150},
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, start.Add(20*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(175), used)
up, down := sumTrafficDeltas(records, previous)
assert.Equal(t, int64(200), up)
assert.Equal(t, int64(200), down)
}
func TestGetClientTrafficInRangeFallsBackForPersistedZeroDeltas(t *testing.T) {
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{}))
clientUUID := "client-zero-deltas"
start := time.Date(2026, 6, 3, 0, 0, 0, 0, time.UTC)
records := []models.Record{
{Client: clientUUID, Time: models.FromTime(start.Add(-5 * time.Minute)), NetTotalUp: 100, NetTotalDown: 200},
{Client: clientUUID, Time: models.FromTime(start.Add(0 * time.Minute)), NetTotalUp: 130, NetTotalDown: 250},
{Client: clientUUID, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 160, NetTotalDown: 310},
{Client: clientUUID, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 30},
{Client: clientUUID, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 40},
}
for _, record := range records {
assert.NoError(t, db.Create(&record).Error)
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, start.Add(20*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(235), used)
func TestSumTrafficDeltasEmpty(t *testing.T) {
up, down := sumTrafficDeltas(nil, nil)
assert.Equal(t, int64(0), up)
assert.Equal(t, int64(0), down)
}
func TestGetClientTrafficInRangeFallsBackForLongTermZeroDeltas(t *testing.T) {
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{}))
clientUUID := "client-long-term-zero-deltas"
start := time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)
assert.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(start.Add(-15 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
longTermRecords := []models.Record{
{Client: clientUUID, Time: models.FromTime(start), NetTotalUp: 140, NetTotalDown: 260},
{Client: clientUUID, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 180, NetTotalDown: 330},
}
for _, record := range longTermRecords {
assert.NoError(t, db.Table("records_long_term").Create(&record).Error)
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, start.Add(30*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(210), used)
func TestTrafficDeltaOrFallback(t *testing.T) {
// 存储的增量为正时直接使用
assert.Equal(t, int64(42), trafficDeltaOrFallback(42, 500, 100))
// 增量缺失(<=0)时回退到累计差值
assert.Equal(t, int64(400), trafficDeltaOrFallback(0, 500, 100))
// 回退路径识别计数器重置
assert.Equal(t, int64(50), trafficDeltaOrFallback(0, 50, 500))
}
func TestGetClientTrafficInRangePrefersRawSlotOverZeroLongTermSlot(t *testing.T) {
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{}))
clientUUID := "client-zero-long-term-with-raw-reset"
start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC)
slot := start.Add(15 * time.Minute)
assert.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(start.Add(-5 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(slot),
NetTotalUp: 15,
NetTotalDown: 25,
TrafficUp: 0,
TrafficDown: 0,
}).Error)
rawRecords := []models.Record{
{Client: clientUUID, Time: models.FromTime(slot.Add(1 * time.Minute)), NetTotalUp: 130, NetTotalDown: 240},
{Client: clientUUID, Time: models.FromTime(slot.Add(5 * time.Minute)), NetTotalUp: 10, NetTotalDown: 20},
{Client: clientUUID, Time: models.FromTime(slot.Add(10 * time.Minute)), NetTotalUp: 15, NetTotalDown: 25},
}
for _, record := range rawRecords {
assert.NoError(t, db.Create(&record).Error)
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, slot.Add(15*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(110), used)
func TestComputeUsedByType(t *testing.T) {
assert.Equal(t, int64(30), computeUsedByType("up", 30, 70))
assert.Equal(t, int64(70), computeUsedByType("down", 30, 70))
assert.Equal(t, int64(100), computeUsedByType("sum", 30, 70))
assert.Equal(t, int64(30), computeUsedByType("min", 30, 70))
assert.Equal(t, int64(70), computeUsedByType("max", 30, 70))
// 未知类型默认取较大值
assert.Equal(t, int64(70), computeUsedByType("unknown", 30, 70))
}
-1
View File
@@ -1,7 +1,6 @@
package oauth
import (
_ "github.com/komari-monitor/komari/web/oauth/cloudflare"
_ "github.com/komari-monitor/komari/web/oauth/factory"
_ "github.com/komari-monitor/komari/web/oauth/generic"
_ "github.com/komari-monitor/komari/web/oauth/github"
-92
View File
@@ -1,92 +0,0 @@
package cloudflare
import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/web/oauth/factory"
)
func (c *Cloudflare) GetName() string {
return "CloudflareAccess"
}
func (c *Cloudflare) GetConfiguration() factory.Configuration {
return &c.Addition
}
func (c *Cloudflare) GetAuthorizationURL(redirectURI string) (string, string) {
// 生成 state
state := utils.GenerateRandomString(8)
// 将 state 作为参数附加到回调 URL
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
authURL := fmt.Sprintf("%s%sstate=%s", redirectURI, sep, state)
return authURL, state
}
func (c *Cloudflare) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 从请求头中获取 Cloudflare Access JWT
accessJWT := ctx.GetHeader("Cf-Access-Jwt-Assertion")
if accessJWT == "" {
return factory.OidcCallback{}, fmt.Errorf("no Cloudflare Access JWT found")
}
// 验证 JWT
teamDomain := c.Addition.TeamDomain
// 如果不是完整的URL,则默认添加 https 前缀和 cloudflareaccess.com 后缀
if !strings.HasPrefix(teamDomain, "https://") {
teamDomain = "https://" + teamDomain + ".cloudflareaccess.com"
}
certsURL := fmt.Sprintf("%s/cdn-cgi/access/certs", teamDomain)
config := &oidc.Config{
ClientID: c.Addition.PolicyAUD,
}
keySet := oidc.NewRemoteKeySet(ctx.Request.Context(), certsURL)
verifier := oidc.NewVerifier(teamDomain, keySet, config)
// 验证 token
idToken, err := verifier.Verify(ctx.Request.Context(), accessJWT)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to verify Cloudflare Access token: %w", err)
}
// 提取用户信息
var claims struct {
// Email string `json:"email"`
Sub string `json:"sub"`
}
if err := idToken.Claims(&claims); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to extract claims from token: %w", err)
}
// 使用 sub 作为用户 ID
return factory.OidcCallback{UserId: claims.Sub}, nil
}
func (c *Cloudflare) Init() error {
if c.Addition.TeamDomain == "" {
return fmt.Errorf("team_domain is required")
}
if c.Addition.PolicyAUD == "" {
return fmt.Errorf("policy_aud is required")
}
return nil
}
func (c *Cloudflare) Destroy() error {
// Cloudflare Access 提供商不需要特殊的清理操作
return nil
}
var _ factory.IOidcProvider = (*Cloudflare)(nil)
-20
View File
@@ -1,20 +0,0 @@
package cloudflare
import (
"github.com/komari-monitor/komari/web/oauth/factory"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &Cloudflare{}
})
}
type Cloudflare struct {
Addition
}
type Addition struct {
TeamDomain string `json:"team_domain" required:"true"`
PolicyAUD string `json:"policy_aud" required:"true"`
}
+28
View File
@@ -18,12 +18,26 @@ var (
once = sync.Once{}
)
const removedCloudflareAccessProvider = "CloudflareAccess"
func CurrentProvider() factory.IOidcProvider {
mu.Lock()
defer mu.Unlock()
return currentProvider
}
// Shutdown 销毁当前 OAuth provider,释放其持有的资源。供关闭流程调用。
func Shutdown() error {
mu.Lock()
defer mu.Unlock()
if currentProvider == nil {
return nil
}
err := currentProvider.Destroy()
currentProvider = nil
return err
}
func LoadProvider(name string, configJson string) error {
mu.Lock()
defer mu.Unlock()
@@ -48,6 +62,7 @@ func LoadProvider(name string, configJson string) error {
}
func Initialize() error {
cleanupRemovedProviders()
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
@@ -88,3 +103,16 @@ func Initialize() error {
}
return nil
}
func cleanupRemovedProviders() {
if err := database.DeleteOidcConfigByName(removedCloudflareAccessProvider); err != nil {
log.Printf("Failed to delete removed OIDC provider %s: %v", removedCloudflareAccessProvider, err)
}
cfg, _ := config.GetAs[string](config.OAuthProviderKey, "github")
if cfg == removedCloudflareAccessProvider {
if err := config.Set(config.OAuthProviderKey, "github"); err != nil {
log.Printf("Failed to reset removed OIDC provider %s: %v", removedCloudflareAccessProvider, err)
}
}
}
+73 -72
View File
@@ -1,6 +1,7 @@
package report
import (
"context"
"fmt"
"log"
"sort"
@@ -9,12 +10,11 @@ import (
"time"
"github.com/komari-monitor/komari/database/clients"
"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/protocol/v1"
"github.com/komari-monitor/komari/utils"
"github.com/patrickmn/go-cache"
"gorm.io/gorm"
)
var Records = cache.New(1*time.Minute, 1*time.Minute)
@@ -36,11 +36,15 @@ func AppendClientReport(uuid string, report v1.Report) (v1.Report, error) {
return report, nil
}
// SaveClientReportToDB 把缓存中的报告聚合后写入 metric store。
//
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
// MySQL/PostgreSQL),运行期的读写全部走 metric store,不再写旧 records 表。
func SaveClientReportToDB() error {
return saveClientReportToDB(dbcore.GetDBInstance(), time.Now())
return saveClientReportToDB(time.Now())
}
func saveClientReportToDB(db *gorm.DB, now time.Time) error {
func saveClientReportToDB(now time.Time) error {
saveClientReportMu.Lock()
defer saveClientReportMu.Unlock()
@@ -50,6 +54,8 @@ func saveClientReportToDB(db *gorm.DB, now time.Time) error {
trafficByRecord := make(map[string]cachedTrafficSummary)
reportCacheMu.Lock()
// 先收集所有需要保存的数据,但不修改缓存
filteredByUUID := make(map[string][]v1.Report)
for uuid, x := range Records.Items() {
func() {
if uuid == "" {
@@ -73,7 +79,7 @@ func saveClientReportToDB(db *gorm.DB, now time.Time) error {
}
}
Records.Set(uuid, filtered, cache.DefaultExpiration)
filteredByUUID[uuid] = filtered
if len(filtered) > 0 {
r := utils.AverageReport(uuid, now, filtered, 0.3)
@@ -99,18 +105,18 @@ func saveClientReportToDB(db *gorm.DB, now time.Time) error {
dedupedTraffic[key] = summary
}
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := fillTrafficDeltas(tx, deduped, dedupedTraffic); err != nil {
return err
}
if err := tx.Model(&models.Record{}).Create(&deduped).Error; err != nil {
return err
}
return nil
}); err != nil {
log.Printf("Failed to save records to database: %v", err)
// 增量基线来自 metric store,记录写入 metric store。
if err := fillTrafficDeltasFromMetricStore(deduped, dedupedTraffic); err != nil {
log.Printf("Failed to fill traffic deltas from metric store: %v", err)
return err
}
ctx := context.Background()
for i := range deduped {
if err := metricstore.WriteRecord(ctx, deduped[i]); err != nil {
log.Printf("Failed to write record to metric store: %v", err)
return err
}
}
}
if len(gpuRecords) > 0 {
@@ -119,16 +125,38 @@ func saveClientReportToDB(db *gorm.DB, now time.Time) error {
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
gpuUnique[key] = rec
}
var gpuDeduped []models.GPURecord
ctx := context.Background()
for _, rec := range gpuUnique {
gpuDeduped = append(gpuDeduped, rec)
}
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
log.Printf("Failed to save GPU records to database: %v", err)
return err
if err := metricstore.WriteGPURecord(ctx, rec); err != nil {
log.Printf("Failed to write GPU record to metric store: %v", err)
return err
}
}
}
// 数据成功写入后,才清理缓存中已处理的旧数据。
// 这里重新从当前缓存读取并按时间过滤,保留最近一分钟内的报告
// (包括写库期间新到达的报告),避免写库失败时丢失尚未持久化的历史数据。
reportCacheMu.Lock()
for uuid := range filteredByUUID {
cached, ok := Records.Get(uuid)
if !ok || cached == nil {
continue
}
reports, ok := cached.([]v1.Report)
if !ok {
continue
}
var remaining []v1.Report
for _, r := range reports {
if r.UpdatedAt.Unix() >= lastMinute {
remaining = append(remaining, r)
}
}
Records.Set(uuid, remaining, cache.DefaultExpiration)
}
reportCacheMu.Unlock()
return nil
}
@@ -171,13 +199,16 @@ func summarizeCachedTraffic(reports []v1.Report) cachedTrafficSummary {
}
type previousTrafficRecord struct {
Client string `gorm:"column:client"`
Time models.LocalTime `gorm:"column:time"`
NetTotalUp int64 `gorm:"column:net_total_up"`
NetTotalDown int64 `gorm:"column:net_total_down"`
Client string
Time models.LocalTime
NetTotalUp int64
NetTotalDown int64
}
func fillTrafficDeltas(db *gorm.DB, records []models.Record, trafficByRecord map[string]cachedTrafficSummary) error {
// fillTrafficDeltasFromMetricStore 计算流量增量,基线(上一周期累计流量)从
// metric store 查询。
func fillTrafficDeltasFromMetricStore(records []models.Record, trafficByRecord map[string]cachedTrafficSummary) error {
ctx := context.Background()
recordsByTime := make(map[time.Time][]int)
for i := range records {
before := records[i].Time.ToTime().Round(0)
@@ -199,28 +230,33 @@ func fillTrafficDeltas(db *gorm.DB, records []models.Record, trafficByRecord map
clientUUIDs = append(clientUUIDs, clientUUID)
}
previousByClient, err := getLatestTrafficRecordsBefore(db, clientUUIDs, before)
baseline, err := metricstore.GetLatestTrafficBefore(ctx, clientUUIDs, before)
if err != nil {
return fmt.Errorf("load previous traffic records before %s: %w", before.Format(time.RFC3339), err)
return fmt.Errorf("load previous traffic from metric store before %s: %w", before.Format(time.RFC3339), err)
}
for _, index := range indexes {
var prev *previousTrafficRecord
if base, ok := baseline[records[index].Client]; ok {
prev = &previousTrafficRecord{
Client: base.Client,
Time: base.Time,
NetTotalUp: base.NetTotalUp,
NetTotalDown: base.NetTotalDown,
}
}
key := recordDedupKey(records[index])
if summary, ok := trafficByRecord[key]; ok && len(summary.Points) > 0 {
if previous, exists := previousByClient[records[index].Client]; exists {
records[index].TrafficUp, records[index].TrafficDown = sumCachedTrafficDeltas(summary, &previous)
} else {
records[index].TrafficUp, records[index].TrafficDown = sumCachedTrafficDeltas(summary, nil)
}
records[index].TrafficUp, records[index].TrafficDown = sumCachedTrafficDeltas(summary, prev)
continue
}
previous, exists := previousByClient[records[index].Client]
if !exists {
if prev == nil {
continue
}
records[index].TrafficUp = utils.ComputeTrafficDelta(records[index].NetTotalUp, previous.NetTotalUp)
records[index].TrafficDown = utils.ComputeTrafficDelta(records[index].NetTotalDown, previous.NetTotalDown)
records[index].TrafficUp = utils.ComputeTrafficDelta(records[index].NetTotalUp, prev.NetTotalUp)
records[index].TrafficDown = utils.ComputeTrafficDelta(records[index].NetTotalDown, prev.NetTotalDown)
}
}
@@ -261,38 +297,3 @@ func sumCachedTrafficDeltas(summary cachedTrafficSummary, previous *previousTraf
}
return totalUp, totalDown
}
func getLatestTrafficRecordsBefore(db *gorm.DB, clientUUIDs []string, before time.Time) (map[string]previousTrafficRecord, error) {
previousByClient := make(map[string]previousTrafficRecord, len(clientUUIDs))
if len(clientUUIDs) == 0 {
return previousByClient, nil
}
for _, table := range []string{"records", "records_long_term"} {
records, err := latestTrafficRecordsBeforeFromTable(db, table, clientUUIDs, before)
if err != nil {
return nil, err
}
for _, record := range records {
previous, exists := previousByClient[record.Client]
if !exists || record.Time.ToTime().After(previous.Time.ToTime()) {
previousByClient[record.Client] = record
}
}
}
return previousByClient, nil
}
func latestTrafficRecordsBeforeFromTable(db *gorm.DB, table string, clientUUIDs []string, before time.Time) ([]previousTrafficRecord, error) {
var records []previousTrafficRecord
latestPerClient := db.Table(table).
Select("client, MAX(time) AS time").
Where("client IN ? AND time < ?", clientUUIDs, models.FromTime(before)).
Group("client")
err := db.Table(table+" AS r").
Select("r.client, r.time, r.net_total_up, r.net_total_down").
Joins("JOIN (?) AS latest ON latest.client = r.client AND latest.time = r.time", latestPerClient).
Find(&records).Error
return records, err
}
+61 -264
View File
@@ -9,20 +9,8 @@ import (
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openReportCacheTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Record{}))
require.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
require.NoError(t, db.AutoMigrate(&models.GPURecord{}))
return db
}
func resetReportCache(t *testing.T) {
t.Helper()
Records.Flush()
@@ -71,276 +59,85 @@ func TestAppendClientReportRejectsCorruptedCacheValue(t *testing.T) {
assert.Contains(t, err.Error(), "invalid report type")
}
func TestSaveClientReportToDBStoresCachedTrafficDelta(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-cached-delta"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-2 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
Records.Set(clientUUID, []v1.Report{
{
UpdatedAt: now.Add(-30 * time.Second),
Network: v1.NetworkReport{TotalUp: 130, TotalDown: 260},
},
{
UpdatedAt: now.Add(-10 * time.Second),
Network: v1.NetworkReport{TotalUp: 175, TotalDown: 320},
},
}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, now))
var saved models.Record
require.NoError(t, db.Where("client = ? AND time = ?", clientUUID, models.FromTime(now)).First(&saved).Error)
assert.Equal(t, int64(175), saved.NetTotalUp)
assert.Equal(t, int64(320), saved.NetTotalDown)
assert.Equal(t, int64(75), saved.TrafficUp)
assert.Equal(t, int64(120), saved.TrafficDown)
// newCachedTrafficSummary 构造按时间递增的缓存流量汇总,便于测试增量累加逻辑。
func newCachedTrafficSummary(base time.Time, points ...[2]int64) cachedTrafficSummary {
pts := make([]trafficTotalPoint, 0, len(points))
for i, p := range points {
pts = append(pts, trafficTotalPoint{
Time: base.Add(time.Duration(i) * time.Second),
TotalUp: p[0],
TotalDown: p[1],
})
}
return cachedTrafficSummary{Points: pts}
}
func TestSaveClientReportToDBStoresCachedTrafficDeltaAfterCounterReset(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
func TestSumCachedTrafficDeltasWithBaseline(t *testing.T) {
base := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
previous := &previousTrafficRecord{
Time: models.FromTime(base.Add(-time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}
summary := newCachedTrafficSummary(base, [2]int64{130, 260}, [2]int64{175, 320})
clientUUID := "client-cached-reset"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-2 * time.Minute)),
up, down := sumCachedTrafficDeltas(summary, previous)
assert.Equal(t, int64(75), up)
assert.Equal(t, int64(120), down)
}
func TestSumCachedTrafficDeltasWithBaselineAfterCounterReset(t *testing.T) {
base := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
previous := &previousTrafficRecord{
Time: models.FromTime(base.Add(-time.Minute)),
NetTotalUp: 500,
NetTotalDown: 700,
}).Error)
}
summary := newCachedTrafficSummary(base, [2]int64{30, 45}, [2]int64{40, 55})
Records.Set(clientUUID, []v1.Report{
{
UpdatedAt: now.Add(-20 * time.Second),
Network: v1.NetworkReport{TotalUp: 30, TotalDown: 45},
},
{
UpdatedAt: now.Add(-5 * time.Second),
Network: v1.NetworkReport{TotalUp: 40, TotalDown: 55},
},
}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, now))
var saved models.Record
require.NoError(t, db.Where("client = ? AND time = ?", clientUUID, models.FromTime(now)).First(&saved).Error)
assert.Equal(t, int64(40), saved.NetTotalUp)
assert.Equal(t, int64(55), saved.NetTotalDown)
assert.Equal(t, int64(40), saved.TrafficUp)
assert.Equal(t, int64(55), saved.TrafficDown)
up, down := sumCachedTrafficDeltas(summary, previous)
assert.Equal(t, int64(40), up)
assert.Equal(t, int64(55), down)
}
func TestSaveClientReportToDBSumsCachedTrafficWithoutPersistedBaseline(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
func TestSumCachedTrafficDeltasWithoutBaseline(t *testing.T) {
base := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
summary := newCachedTrafficSummary(base,
[2]int64{100, 200}, [2]int64{130, 250}, [2]int64{155, 280})
clientUUID := "client-cached-no-baseline"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
Records.Set(clientUUID, []v1.Report{
{
UpdatedAt: now.Add(-40 * time.Second),
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
},
{
UpdatedAt: now.Add(-20 * time.Second),
Network: v1.NetworkReport{TotalUp: 130, TotalDown: 250},
},
{
UpdatedAt: now.Add(-5 * time.Second),
Network: v1.NetworkReport{TotalUp: 155, TotalDown: 280},
},
}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, now))
var saved models.Record
require.NoError(t, db.Where("client = ? AND time = ?", clientUUID, models.FromTime(now)).First(&saved).Error)
assert.Equal(t, int64(155), saved.NetTotalUp)
assert.Equal(t, int64(280), saved.NetTotalDown)
assert.Equal(t, int64(55), saved.TrafficUp)
assert.Equal(t, int64(80), saved.TrafficDown)
up, down := sumCachedTrafficDeltas(summary, nil)
assert.Equal(t, int64(55), up)
assert.Equal(t, int64(80), down)
}
func TestSaveClientReportToDBSumsCachedTrafficAcrossIntraMinuteReset(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-cached-intra-minute-reset"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-2 * time.Minute)),
func TestSumCachedTrafficDeltasAcrossIntraMinuteReset(t *testing.T) {
base := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
previous := &previousTrafficRecord{
Time: models.FromTime(base.Add(-time.Minute)),
NetTotalUp: 500,
NetTotalDown: 700,
}).Error)
}
summary := newCachedTrafficSummary(base,
[2]int64{540, 760}, [2]int64{10, 20}, [2]int64{25, 35})
Records.Set(clientUUID, []v1.Report{
{
UpdatedAt: now.Add(-45 * time.Second),
Network: v1.NetworkReport{TotalUp: 540, TotalDown: 760},
},
{
UpdatedAt: now.Add(-25 * time.Second),
Network: v1.NetworkReport{TotalUp: 10, TotalDown: 20},
},
{
UpdatedAt: now.Add(-5 * time.Second),
Network: v1.NetworkReport{TotalUp: 25, TotalDown: 35},
},
}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, now))
var saved models.Record
require.NoError(t, db.Where("client = ? AND time = ?", clientUUID, models.FromTime(now)).First(&saved).Error)
assert.Equal(t, int64(25), saved.NetTotalUp)
assert.Equal(t, int64(35), saved.NetTotalDown)
assert.Equal(t, int64(65), saved.TrafficUp)
assert.Equal(t, int64(95), saved.TrafficDown)
up, down := sumCachedTrafficDeltas(summary, previous)
assert.Equal(t, int64(65), up)
assert.Equal(t, int64(95), down)
}
func TestSaveClientReportToDBDoesNotRecountRetainedCachePoints(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-retained-cache-point"
firstFlush := time.Date(2026, 6, 13, 12, 0, 30, 0, time.UTC)
secondFlush := firstFlush.Add(30 * time.Second)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(firstFlush.Add(-2 * time.Minute)),
func TestSumCachedTrafficDeltasSkipsPointsAtOrBeforeBaseline(t *testing.T) {
base := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
previous := &previousTrafficRecord{
Time: models.FromTime(base.Add(1 * time.Second)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
}
// 前两个点时间早于/等于基线时间,应被忽略;仅最后一个点(晚于基线)计入。
summary := newCachedTrafficSummary(base,
[2]int64{50, 90}, [2]int64{130, 260}, [2]int64{160, 300})
Records.Set(clientUUID, []v1.Report{{
UpdatedAt: firstFlush.Add(-10 * time.Second),
Network: v1.NetworkReport{TotalUp: 175, TotalDown: 320},
}}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, firstFlush))
reports, ok := Records.Get(clientUUID)
require.True(t, ok)
retained := reports.([]v1.Report)
retained = append(retained, v1.Report{
UpdatedAt: secondFlush.Add(-5 * time.Second),
Network: v1.NetworkReport{TotalUp: 190, TotalDown: 360},
})
Records.Set(clientUUID, retained, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, secondFlush))
var saved []models.Record
require.NoError(t, db.Where("client = ?", clientUUID).Order("time ASC").Find(&saved).Error)
require.Len(t, saved, 3)
assert.Equal(t, int64(75), saved[1].TrafficUp)
assert.Equal(t, int64(120), saved[1].TrafficDown)
assert.Equal(t, int64(15), saved[2].TrafficUp)
assert.Equal(t, int64(40), saved[2].TrafficDown)
up, down := sumCachedTrafficDeltas(summary, previous)
assert.Equal(t, int64(60), up)
assert.Equal(t, int64(100), down)
}
func TestSaveClientReportToDBSkipsInvalidCachedReports(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-invalid-report"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-2 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
Records.Set(clientUUID, []v1.Report{
{
UpdatedAt: now.Add(-30 * time.Second),
Network: v1.NetworkReport{TotalUp: -1, TotalDown: 260},
},
{
UpdatedAt: now.Add(-10 * time.Second),
Network: v1.NetworkReport{TotalUp: 175, TotalDown: 320},
},
}, cache.DefaultExpiration)
require.NoError(t, saveClientReportToDB(db, now))
var saved models.Record
require.NoError(t, db.Where("client = ? AND time = ?", clientUUID, models.FromTime(now)).First(&saved).Error)
assert.Equal(t, int64(175), saved.NetTotalUp)
assert.Equal(t, int64(320), saved.NetTotalDown)
assert.Equal(t, int64(75), saved.TrafficUp)
assert.Equal(t, int64(120), saved.TrafficDown)
}
func TestSaveClientReportToDBIgnoresSameTimeRowsWhenComputingDelta(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-same-time"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now),
NetTotalUp: 999,
NetTotalDown: 999,
}).Error)
records := []models.Record{{
Client: clientUUID,
Time: models.FromTime(now),
NetTotalUp: 175,
NetTotalDown: 320,
}}
require.NoError(t, fillTrafficDeltas(db, records, nil))
assert.Equal(t, int64(75), records[0].TrafficUp)
assert.Equal(t, int64(120), records[0].TrafficDown)
}
func TestSaveClientReportToDBUsesLatestLongTermRecordWhenNewer(t *testing.T) {
resetReportCache(t)
db := openReportCacheTestDB(t)
clientUUID := "client-long-term-previous"
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-10 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
require.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(now.Add(-2 * time.Minute)),
NetTotalUp: 150,
NetTotalDown: 260,
}).Error)
records := []models.Record{{
Client: clientUUID,
Time: models.FromTime(now),
NetTotalUp: 175,
NetTotalDown: 320,
}}
require.NoError(t, fillTrafficDeltas(db, records, nil))
assert.Equal(t, int64(25), records[0].TrafficUp)
assert.Equal(t, int64(60), records[0].TrafficDown)
}
+2 -7
View File
@@ -143,20 +143,15 @@ func registerAdminRoutes(r *gin.Engine) {
settings.GET("/oidc", jsonRpc.Bind("admin:getOidcProvider", jsonRpc.WithQuery("provider")))
settings.POST("/message-sender", jsonRpc.Bind("admin:setMessageSenderProvider"))
settings.GET("/message-sender", jsonRpc.Bind("admin:getMessageSenderProvider", jsonRpc.WithQuery("provider")))
settings.GET("/cloudflared", jsonRpc.Bind("admin:getCloudflaredStatus"))
settings.POST("/cloudflared/start", jsonRpc.Bind("admin:startCloudflared"))
settings.POST("/cloudflared/stop", jsonRpc.Bind("admin:stopCloudflared"))
settings.POST("/cloudflared/remove-token", jsonRpc.Bind("admin:removeCloudflaredToken"))
}
// database 运维(压缩/大小)
// database storage inspection and maintenance
databaseGroup := g.Group("/database")
{
databaseGroup.GET("/size", jsonRpc.Bind("admin:getDatabaseSize"))
databaseGroup.POST("/vacuum", jsonRpc.Bind("admin:vacuumDatabase", jsonRpc.WithMessage("database vacuumed")))
databaseGroup.POST("/vacuum", jsonRpc.Bind("admin:vacuumDatabase"))
}
// clients
clientGroup := g.Group("/client")
{
+235
View File
@@ -0,0 +1,235 @@
package jsonrpc
import (
"context"
"sync"
"github.com/komari-monitor/komari/cmd/flags"
"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/pkg/metric"
"github.com/komari-monitor/komari/pkg/rpc"
)
const (
databaseLocationLocal = "local"
databaseLocationExternal = "external"
)
var databaseMaintenanceMu sync.Mutex
type databaseStorageStatus struct {
Driver string `json:"driver"`
Location string `json:"location"`
Size *int64 `json:"size"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
type databaseStatusResponse struct {
// Type and Size preserve the original main-database-only response contract.
Type string `json:"type"`
Size int64 `json:"size"`
Main databaseStorageStatus `json:"main"`
Monitoring databaseStorageStatus `json:"monitoring"`
LocalTotal *int64 `json:"local_total"`
}
type databaseMaintenanceResult struct {
Driver string `json:"driver"`
Action string `json:"action"`
Before *int64 `json:"before"`
After *int64 `json:"after"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
SizeError string `json:"size_error,omitempty"`
}
type databaseMaintenanceResponse struct {
// Before, After and Size preserve the original main-database-only fields.
Before int64 `json:"before"`
After int64 `json:"after"`
Size int64 `json:"size"`
AllSucceeded bool `json:"all_succeeded"`
Main databaseMaintenanceResult `json:"main"`
Monitoring databaseMaintenanceResult `json:"monitoring"`
}
func init() {
// Retain the original method names so existing REST/RPC clients keep working;
// the result now describes both databases and their driver-specific actions.
reg("getDatabaseSize", adminGetDatabaseSize, "Get main and monitoring database storage usage")
reg("vacuumDatabase", adminVacuumDatabase, "Reclaim space in the main and monitoring databases")
}
func adminGetDatabaseSize(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
main := mainDatabaseStatus()
monitoring := monitoringDatabaseStatus(ctx)
legacySize := int64(0)
if main.Size != nil {
legacySize = *main.Size
}
return databaseStatusResponse{
Type: main.Driver,
Size: legacySize,
Main: main,
Monitoring: monitoring,
LocalTotal: localDatabaseTotal(main, monitoring),
}, nil
}
func adminVacuumDatabase(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if !databaseMaintenanceMu.TryLock() {
return nil, rpc.MakeError(rpc.InternalError, "database maintenance is already in progress", nil)
}
defer databaseMaintenanceMu.Unlock()
main := maintainMainDatabase(ctx)
monitoring := maintainMonitoringDatabase(ctx)
response := newDatabaseMaintenanceResponse(main, monitoring)
actor, ip := auditActor(ctx)
level := "warn"
message := "reclaimed database space"
if !response.AllSucceeded {
message = "database space reclaim completed with errors"
level = "error"
}
auditlog.Log(ip, actor, message, level)
return response, nil
}
func newDatabaseMaintenanceResponse(main, monitoring databaseMaintenanceResult) databaseMaintenanceResponse {
return databaseMaintenanceResponse{
Before: valueOrZero(main.Before),
After: valueOrZero(main.After),
Size: valueOrZero(main.After),
AllSucceeded: main.Success && monitoring.Success,
Main: main,
Monitoring: monitoring,
}
}
func mainDatabaseStatus() databaseStorageStatus {
status := databaseStorageStatus{
Driver: flags.NormalizeDatabaseType(flags.DatabaseType),
Location: databaseLocationLocal,
Action: string(metric.MaintenanceVacuum),
}
size, err := dbcore.StorageSize()
if err != nil {
status.Error = err.Error()
return status
}
status.Size = int64Pointer(size)
return status
}
func monitoringDatabaseStatus(ctx context.Context) databaseStorageStatus {
info, err := metricstore.InspectStorage(ctx)
status := databaseStorageStatus{
Driver: string(info.Driver),
Action: string(info.Action),
}
status.Location = databaseLocationForDriver(info.Driver)
if err != nil {
status.Error = err.Error()
return status
}
status.Size = int64Pointer(info.Size)
return status
}
func maintainMainDatabase(ctx context.Context) databaseMaintenanceResult {
status := mainDatabaseStatus()
result := databaseMaintenanceResult{
Driver: status.Driver,
Action: status.Action,
Before: status.Size,
}
if status.Error != "" {
result.SizeError = "before: " + status.Error
}
if !flags.IsSQLite() {
result.Error = "main database maintenance is only supported for SQLite"
} else if err := dbcore.ReclaimSpace(ctx); err != nil {
result.Error = err.Error()
} else {
result.Success = true
}
after, err := dbcore.StorageSize()
if err != nil {
result.SizeError = appendMeasurementError(result.SizeError, "after", err)
} else {
result.After = int64Pointer(after)
}
return result
}
func maintainMonitoringDatabase(ctx context.Context) databaseMaintenanceResult {
maintained, maintenanceErr := metricstore.ReclaimSpace(ctx)
result := databaseMaintenanceResult{
Driver: string(maintained.Driver),
Action: string(maintained.Action),
Success: maintenanceErr == nil,
}
if maintained.BeforeSizeError != nil {
result.SizeError = appendMeasurementError(result.SizeError, "before", maintained.BeforeSizeError)
} else {
result.Before = int64Pointer(maintained.Before)
}
if maintained.AfterSizeError != nil {
result.SizeError = appendMeasurementError(result.SizeError, "after", maintained.AfterSizeError)
} else {
result.After = int64Pointer(maintained.After)
}
if maintenanceErr != nil {
result.Error = maintenanceErr.Error()
}
return result
}
func databaseLocationForDriver(driver metric.Driver) string {
if driver == metric.DriverSQLite {
return databaseLocationLocal
}
if driver == "" {
return ""
}
return databaseLocationExternal
}
func localDatabaseTotal(statuses ...databaseStorageStatus) *int64 {
var total int64
for _, status := range statuses {
if status.Location != databaseLocationLocal || status.Size == nil {
return nil
}
total += *status.Size
}
return int64Pointer(total)
}
func appendMeasurementError(current, phase string, err error) string {
next := phase + ": " + err.Error()
if current == "" {
return next
}
return current + "; " + next
}
func int64Pointer(value int64) *int64 {
return &value
}
func valueOrZero(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
+74
View File
@@ -0,0 +1,74 @@
package jsonrpc
import (
"testing"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestLocalDatabaseTotalRequiresTwoKnownLocalSizes(t *testing.T) {
mainSize, monitoringSize := int64(10), int64(15)
main := databaseStorageStatus{Location: databaseLocationLocal, Size: &mainSize}
monitoring := databaseStorageStatus{Location: databaseLocationLocal, Size: &monitoringSize}
total := localDatabaseTotal(main, monitoring)
if total == nil || *total != 25 {
t.Fatalf("local total = %v, want 25", total)
}
monitoring.Location = databaseLocationExternal
if total := localDatabaseTotal(main, monitoring); total != nil {
t.Fatalf("external monitoring database should not produce a local total: %d", *total)
}
monitoring.Location = databaseLocationLocal
monitoring.Size = nil
if total := localDatabaseTotal(main, monitoring); total != nil {
t.Fatalf("unknown monitoring size should not produce a local total: %d", *total)
}
}
func TestDatabaseLocationForDriver(t *testing.T) {
tests := []struct {
driver metric.Driver
want string
}{
{driver: metric.DriverSQLite, want: databaseLocationLocal},
{driver: metric.DriverMySQL, want: databaseLocationExternal},
{driver: metric.DriverPostgreSQL, want: databaseLocationExternal},
{driver: "", want: ""},
}
for _, test := range tests {
if got := databaseLocationForDriver(test.driver); got != test.want {
t.Errorf("databaseLocationForDriver(%q) = %q, want %q", test.driver, got, test.want)
}
}
}
func TestDatabaseMaintenanceResponsePreservesLegacyMainSizes(t *testing.T) {
before, after := int64(100), int64(60)
for _, test := range []struct {
name string
mainSuccess bool
monitoringSuccess bool
wantAllSucceeded bool
}{
{name: "both succeed", mainSuccess: true, monitoringSuccess: true, wantAllSucceeded: true},
{name: "main fails", mainSuccess: false, monitoringSuccess: true, wantAllSucceeded: false},
{name: "monitoring fails", mainSuccess: true, monitoringSuccess: false, wantAllSucceeded: false},
{name: "both fail", mainSuccess: false, monitoringSuccess: false, wantAllSucceeded: false},
} {
t.Run(test.name, func(t *testing.T) {
response := newDatabaseMaintenanceResponse(
databaseMaintenanceResult{Before: &before, After: &after, Success: test.mainSuccess},
databaseMaintenanceResult{Success: test.monitoringSuccess},
)
if response.AllSucceeded != test.wantAllSucceeded {
t.Fatalf("all_succeeded = %t, want %t", response.AllSucceeded, test.wantAllSucceeded)
}
if response.Before != before || response.After != after || response.Size != after {
t.Fatalf("legacy sizes changed: before=%d after=%d size=%d", response.Before, response.After, response.Size)
}
})
}
}
+210
View File
@@ -0,0 +1,210 @@
package jsonrpc
import (
"context"
"encoding/json"
"strings"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.metric.go
// Metrics 数据库迁移相关 RPC 方法(admin 命名空间)。
//
// 语义变更:过去这些方法用于把旧 komari.db 的 records/ping 表迁移到 metric store。
// 现在“旧表 → metrics”已改为进程启动时自动一次性完成(见 metricstore.RunStartupMigration),
// 无需 WebUI 介入。因此这些方法现在服务于「metrics 存储后端迁移」——即把默认
// SQLite(./data/metrics.db)中的历史数据搬运到管理员新配置的 MySQL/PostgreSQL。
//
// 典型使用顺序(WebUI):
// 1. admin:editSettings 修改 metric_db_dsn 为 MySQL/PostgreSQL(后端会做连接测试
// 并热重载,当前 store 切到远端,此时远端为空)。
// 2. admin:startMetricMigration 触发把旧 SQLite metrics 数据搬运到远端。
// 3. 轮询 admin:getMetricMigrationStatus 展示进度。
// 4.(可选)admin:cancelMetricMigration 取消;因写入幂等,取消后可安全重发。
func init() {
reg("listMetricDefinitions", adminListMetricDefinitions, "List metric definitions and retention policies")
reg("updateMetricDefinition", adminUpdateMetricDefinition, "Update a metric definition")
reg("getMetricMigrationStatus", adminGetMetricMigrationStatus, "Get metrics store migration status (SQLite -> MySQL/PostgreSQL)")
reg("startMetricMigration", adminStartMetricMigration, "Start migrating metrics data from source SQLite to the current MySQL/PostgreSQL target")
reg("cancelMetricMigration", adminCancelMetricMigration, "Cancel the currently running metrics store migration")
}
type metricDefinitionResponse struct {
Name string `json:"name"`
Description any `json:"description,omitempty"`
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
RetentionDays int `json:"retention_days"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt any `json:"created_at,omitempty"`
UpdatedAt any `json:"updated_at,omitempty"`
}
func metricDescriptionValue(raw string) any {
desc := strings.TrimSpace(raw)
if desc == "" {
return ""
}
var dict map[string]string
if err := json.Unmarshal([]byte(desc), &dict); err == nil && len(dict) > 0 {
return dict
}
return raw
}
func adminListMetricDefinitions(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
store := metricstore.GetStore()
if store == nil {
return nil, rpc.MakeError(rpc.InternalError, "metric store not initialized", nil)
}
defs, err := store.ListMetrics(ctx)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to list metric definitions: "+err.Error(), nil)
}
out := make([]metricDefinitionResponse, 0, len(defs))
for _, def := range defs {
out = append(out, metricDefinitionResponse{
Name: def.Name,
Description: metricDescriptionValue(def.Description),
Type: string(def.Type),
Unit: def.Unit,
RetentionDays: def.RetentionDays,
Metadata: def.Metadata,
CreatedAt: def.CreatedAt,
UpdatedAt: def.UpdatedAt,
})
}
return out, nil
}
func adminUpdateMetricDefinition(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Name string `json:"name"`
RetentionDays int `json:"retention_days"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
params.Name = strings.TrimSpace(params.Name)
if params.Name == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "name is required", nil)
}
if params.RetentionDays <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "retention_days must be a positive integer", nil)
}
store := metricstore.GetStore()
if store == nil {
return nil, rpc.MakeError(rpc.InternalError, "metric store not initialized", nil)
}
def, err := store.GetMetric(ctx, params.Name)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "metric not found: "+params.Name, nil)
}
def.RetentionDays = params.RetentionDays
if err := store.UpsertMetric(ctx, def); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update metric definition: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "update metric definition: "+params.Name, "info")
def, err = store.GetMetric(ctx, params.Name)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to reload metric definition: "+err.Error(), nil)
}
return metricDefinitionResponse{
Name: def.Name,
Description: metricDescriptionValue(def.Description),
Type: string(def.Type),
Unit: def.Unit,
RetentionDays: def.RetentionDays,
Metadata: def.Metadata,
CreatedAt: def.CreatedAt,
UpdatedAt: def.UpdatedAt,
}, nil
}
// adminGetMetricMigrationStatus 返回当前 store-to-store 迁移进度快照。
//
// 返回字段:
// - status: idle | running | completed | failed | canceled
// - is_running: 是否有迁移正在进行
// - source_driver: 源库驱动(如 sqlite)
// - source_dsn: 源库 DSN(脱敏)
// - target_driver: 目标库驱动(如 mysql / postgresql)
// - target_dsn: 目标库 DSN(脱敏)
// - total_metrics: 指标定义总数
// - metrics_done: 已完成的指标数
// - current_metric: 当前正在搬运的指标名
// - migrated_points: 已搬运的采样点数
// - start_time / end_time / error
func adminGetMetricMigrationStatus(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
p := metricstore.GetStoreMigrationProgress()
status := p.Status
if status == "" {
status = "idle"
}
return map[string]any{
"status": status,
"is_running": metricstore.IsStoreMigrationRunning(),
"source_driver": p.SourceDriver,
"source_dsn": p.SourceDSN,
"target_driver": p.TargetDriver,
"target_dsn": p.TargetDSN,
"total_metrics": p.TotalMetrics,
"metrics_done": p.MetricsDone,
"current_metric": p.CurrentMetric,
"migrated_points": p.MigratedPoints,
"start_time": p.StartTime,
"end_time": p.EndTime,
"error": p.Error,
}, nil
}
// adminStartMetricMigration 启动一次 store-to-store 迁移。
//
// 参数(均可选):
// - source_driver: 源库驱动;留空时由 source_dsn 推断。
// - source_dsn: 源库 DSN;留空时使用「上一个已保存的 metrics 目标」,
// 再退化到默认 SQLite(./data/metrics.db)。
//
// 目标固定为“当前运行中的 metric store”(即 editSettings 切换并热重载后的库)。
func adminStartMetricMigration(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
SourceDriver string `json:"source_driver"`
SourceDSN string `json:"source_dsn"`
}
req.BindParams(&params)
if err := metricstore.StartStoreMigration(strings.TrimSpace(params.SourceDriver), strings.TrimSpace(params.SourceDSN)); err != nil {
return nil, rpc.MakeError(rpc.InvalidRequest, err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "start metrics store migration", "info")
return map[string]any{
"status": "started",
"message": "Metrics store migration started in background",
}, nil
}
// adminCancelMetricMigration 取消正在运行的 store-to-store 迁移。
// 因写入是幂等 upsert,取消后可安全重新发起,不会产生重复数据。
func adminCancelMetricMigration(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if err := metricstore.CancelStoreMigration(); err != nil {
return nil, rpc.MakeError(rpc.InvalidRequest, err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "cancel metrics store migration", "warn")
return map[string]any{
"status": "canceled",
"message": "Metrics store migration canceled. It can be safely restarted.",
}, nil
}
+141
View File
@@ -3,10 +3,13 @@ package jsonrpc
import (
"context"
"strconv"
"strings"
"time"
"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"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
@@ -105,14 +108,81 @@ func adminGetSettings(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonR
return cst, nil
}
// metricStoreConfigKeys 是与 metrics 独立数据库相关、需要触发连接测试 + 热重载的配置键。
//
// 注意:metric_store_enabled 已废弃(metric store 始终启用),不再纳入此集合。
var metricStoreConfigKeys = map[string]struct{}{
metricstore.MetricDBDriverKey: {},
metricstore.MetricDBDSNKey: {},
metricstore.MetricRetentionDaysKey: {},
metricstore.MetricDownsamplingEnabledKey: {},
metricstore.MetricTablePrefixKey: {},
metricstore.MetricMaxOpenConnsKey: {},
metricstore.MetricMaxIdleConnsKey: {},
}
// metricKeysTouched 判断本次设置变更是否涉及 metrics 数据库相关键。
func metricKeysTouched(cfg map[string]interface{}) bool {
for key := range cfg {
if _, ok := metricStoreConfigKeys[key]; ok {
return true
}
}
return false
}
func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
cfg := make(map[string]interface{})
if err := req.BindParams(&cfg); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
// 若本次修改涉及 metrics 数据库配置,则在落库前先用「当前配置 + 本次改动」
// 合并出的目标配置做一次连接测试。metric store 始终启用,只要触及 metrics
// 相关键就做连接测试,避免把明显无效的连接串保存给用户。
touchedMetric := metricKeysTouched(cfg)
if touchedMetric {
// 数据库类型不再由前端显式选择,而是根据 DSN 自动推断后写回配置,
// 使后续连接测试、热重载和初始化都使用一致的 driver。
if v, ok := cfg[metricstore.MetricDBDSNKey]; ok {
if dsn, ok := v.(string); ok {
dsn = strings.TrimSpace(dsn)
cfg[metricstore.MetricDBDSNKey] = dsn
if driver, inferred := metricstore.InferDriverFromDSN(dsn); inferred {
cfg[metricstore.MetricDBDriverKey] = string(driver)
}
}
}
merged, err := mergedMetricConfig(cfg)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to resolve metric store config: "+err.Error(), nil)
}
testCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := metricstore.TestConnection(testCtx, merged); err != nil {
cancel()
return nil, rpc.MakeError(rpc.InvalidParams,
"Metrics database connection test failed: "+err.Error(), nil)
}
cancel()
}
if err := config.SetMany(cfg); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update settings: "+err.Error(), nil)
}
// 配置已落库,热重载 metric store(无需重启)。连接已在上面验证过,
// 这里再次失败属异常情况,回报给用户。
if touchedMetric {
reloadCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
if err := metricstore.Reload(reloadCtx); err != nil {
cancel()
return nil, rpc.MakeError(rpc.InternalError,
"Settings saved but metrics database hot reload failed: "+err.Error(), nil)
}
cancel()
}
message := "update settings: "
for key := range cfg {
message += key + ", "
@@ -125,7 +195,78 @@ func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.
return nil, nil
}
// mergedMetricConfig 读取当前持久化的 metric store 配置,并把本次请求中涉及的
// metrics 相关键覆盖上去,得到「即将生效」的目标配置,用于落库前的连接测试。
func mergedMetricConfig(cfg map[string]interface{}) (*metricstore.MetricStoreConfig, error) {
merged, err := config.GetManyAs[metricstore.MetricStoreConfig]()
if err != nil {
return nil, err
}
if v, ok := cfg[metricstore.MetricDBDriverKey]; ok {
if s, ok := v.(string); ok {
merged.Driver = s
}
}
if v, ok := cfg[metricstore.MetricDBDSNKey]; ok {
if s, ok := v.(string); ok {
merged.DSN = s
}
}
if v, ok := cfg[metricstore.MetricRetentionDaysKey]; ok {
merged.RetentionDays = toInt(v, merged.RetentionDays)
}
if v, ok := cfg[metricstore.MetricDownsamplingEnabledKey]; ok {
merged.DownsamplingEnabled = toBool(v, merged.DownsamplingEnabled)
}
if v, ok := cfg[metricstore.MetricTablePrefixKey]; ok {
if s, ok := v.(string); ok {
merged.TablePrefix = s
}
}
if v, ok := cfg[metricstore.MetricMaxOpenConnsKey]; ok {
merged.MaxOpenConns = toInt(v, merged.MaxOpenConns)
}
if v, ok := cfg[metricstore.MetricMaxIdleConnsKey]; ok {
merged.MaxIdleConns = toInt(v, merged.MaxIdleConns)
}
return merged, nil
}
func toBool(v any, fallback bool) bool {
switch val := v.(type) {
case bool:
return val
case string:
if parsed, err := strconv.ParseBool(val); err == nil {
return parsed
}
}
return fallback
}
// toInt 将 JSON 解码得到的任意值(通常是 float64 或 string)转换为 int,失败时返回 fallback。
func toInt(v any, fallback int) int {
switch val := v.(type) {
case float64:
return int(val)
case int:
return val
case int64:
return int(val)
case string:
if n, err := strconv.Atoi(val); err == nil {
return n
}
}
return fallback
}
func adminClearAllRecords(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
actor, ip := auditActor(ctx)
+4 -131
View File
@@ -4,99 +4,38 @@ import (
"context"
"encoding/json"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/cmd/flags"
"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"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// admin.system.go
// 系统/运维类 RPC2 方法(admin 命名空间):日志、cloudflared、远程执行、测试。
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
// 系统/运维类 RPC2 方法(admin 命名空间):日志、远程执行、测试。
func init() {
reg("getLogs", adminGetLogs, "Get audit logs (paged)")
reg("getCloudflaredStatus", adminCloudflaredStatus, "Get cloudflared tunnel status")
reg("startCloudflared", adminStartCloudflared, "Start cloudflared tunnel")
reg("stopCloudflared", adminStopCloudflared, "Stop cloudflared tunnel")
reg("removeCloudflaredToken", adminRemoveCloudflaredToken, "Remove cloudflared token")
reg("exec", adminExec, "Execute a command on clients")
reg("testSendMessage", adminTestSendMessage, "Send a test notification")
reg("testGeoip", adminTestGeoip, "Test GeoIP lookup")
reg("getDatabaseSize", adminGetDatabaseSize, "Get the database file size on disk")
reg("vacuumDatabase", adminVacuumDatabase, "Vacuum (compact) the SQLite database to reclaim disk space")
// 远程命令执行属敏感操作:除 admin 角色外,还需通过敏感操作二次验证。
rpc.MarkSensitive("admin:exec")
}
// databaseFileSize 统计 SQLite 数据库文件及其 WAL/SHM 附属文件占用的磁盘大小。
func databaseFileSize() int64 {
if !flags.IsSQLite() {
return 0
}
var total int64
for _, suffix := range []string{"", "-wal", "-shm"} {
if info, err := os.Stat(flags.DatabaseFile + suffix); err == nil {
total += info.Size()
}
}
return total
}
func adminGetDatabaseSize(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return map[string]any{
"type": flags.NormalizeDatabaseType(flags.DatabaseType),
"size": databaseFileSize(),
}, nil
}
func adminVacuumDatabase(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if !flags.IsSQLite() {
return nil, rpc.MakeError(rpc.InvalidParams, "VACUUM is only supported for SQLite databases", nil)
}
before := databaseFileSize()
db := dbcore.GetDBInstance()
// 先做一次 WAL checkpoint,把 WAL 中的内容合并回主库,确保 VACUUM 能回收最多空间。
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
if err := db.Exec("VACUUM;").Error; err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to vacuum database: "+err.Error(), nil)
}
// VACUUM 后再次 checkpoint,回收 WAL 占用。
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
after := databaseFileSize()
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "vacuumed database", "warn")
return map[string]any{
"before": before,
"after": after,
"size": after,
}, nil
}
func adminGetLogs(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Limit string `json:"limit"`
@@ -130,78 +69,12 @@ func adminGetLogs(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
return map[string]any{"logs": logs, "total": total}, nil
}
func adminCloudflaredStatus(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return cloudflared.Status(), nil
}
func adminStartCloudflared(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Token string `json:"token"`
}
req.BindParams(&params)
token := strings.TrimSpace(params.Token)
if token != "" {
if err := cloudflared.SaveToken(token); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save Cloudflare Tunnel token: "+err.Error(), nil)
}
}
if err := cloudflared.Start(token); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "started cloudflared tunnel", "warn")
return cloudflared.Status(), nil
}
func adminStopCloudflared(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
CurrentPassword string `json:"current_password"`
ConfirmText string `json:"confirm_text"`
}
req.BindParams(&params)
disablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
if !disablePasswordLogin {
actor, _ := auditActor(ctx)
if actor == "" {
return nil, rpc.MakeError(rpc.Unauthenticated, "Unauthorized.", nil)
}
user, err := accounts.GetUserByUUID(actor)
if err != nil {
return nil, rpc.MakeError(rpc.Unauthenticated, "Failed to verify current user", nil)
}
if strings.TrimSpace(params.CurrentPassword) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Current password is required", nil)
}
if _, ok := accounts.CheckPassword(user.Username, params.CurrentPassword); !ok {
return nil, rpc.MakeError(rpc.Unauthenticated, "Current password is incorrect", nil)
}
} else if strings.TrimSpace(params.ConfirmText) != cloudflaredStopConfirmText {
return nil, rpc.MakeError(rpc.InvalidParams, "Type STOP CLOUDFLARED to confirm stopping cloudflared", nil)
}
if err := cloudflared.Stop(); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to stop cloudflared: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "stopped cloudflared tunnel", "warn")
return cloudflared.Status(), nil
}
func adminRemoveCloudflaredToken(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if err := cloudflared.RemoveToken(); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Failed to remove Cloudflare Tunnel token: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "removed cloudflared tunnel token", "warn")
return cloudflared.Status(), nil
}
func adminExec(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Command string `json:"command"`
Clients []string `json:"clients"`
}
req.BindParams(&params)
if strings.TrimSpace(params.Command) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Command cannot be empty", nil)
+8 -2
View File
@@ -180,7 +180,7 @@ func init() {
Type: "string",
},
},
Returns: "Client | Client[]",
Returns: "Client | { [uuid]: Client }",
},
)
RegisterWithGroupAndMeta("getNodesLatestStatus", "common",
@@ -262,9 +262,15 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
return cinfo, nil
// 返回以 uuid 为键的字典(每个 value 自身也包含 uuid 字段)
nodeMap := make(map[string]models.Client, len(cinfo))
for _, node := range cinfo {
nodeMap[node.UUID] = node
}
return nodeMap, nil
}
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
info, err := database.GetPublicInfo()
if err != nil {
+3 -40
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
recordsdb "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
@@ -510,47 +509,11 @@ func getLoadRecordsCombined(uuid string, start, end time.Time) ([]models.Record,
if uuid != "" {
return recordsdb.GetRecordsByClientAndTime(uuid, start, end)
}
db := dbcore.GetDBInstance()
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recent []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
_ = db.Table("records").Where("time >= ? AND time <= ?", recentStart, end).Order("time ASC").Find(&recent).Error
}
var longTerm []models.Record
_ = db.Table("records_long_term").Where("time >= ? AND time <= ?", start, end).Order("time ASC").Find(&longTerm).Error
// if no long term, return all recent
if len(longTerm) == 0 {
return recent, nil
}
// group recent by client+15min, keep latest in bucket
type key struct {
c string
slot string
}
grouped := make(map[key]models.Record)
for _, rec := range recent {
k := key{c: rec.Client, slot: rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)}
if old, ok := grouped[k]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[k] = rec
}
}
flat := make([]models.Record, 0, len(grouped))
for _, rec := range grouped {
flat = append(flat, rec)
}
sort.Slice(flat, func(i, j int) bool { return flat[i].Time.ToTime().Before(flat[j].Time.ToTime()) })
flat = append(flat, longTerm...)
return flat, nil
// 所有客户端:统一通过 records 包查询,启用 metric store 时自动走 metric store
return recordsdb.GetRecordsByTime(start, end)
}
// ---------- downsampling helpers ----------
// allocateTargets splits maxTotal across groups proportionally to their lengths.
File diff suppressed because it is too large. Load diff
+255
View File
@@ -0,0 +1,255 @@
package jsonrpc
import (
"encoding/json"
"math"
"strings"
"testing"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestSplitPublicMetricSeriesKeepsTagSeries(t *testing.T) {
base := publicMetricSeries{
MetricKey: "gpu.device.usage",
EntityID: "node-a",
Points: []publicMetricPoint{
{Time: "2026-06-18T00:00:00Z", Value: publicMetricValue(10), Count: 2, Tags: map[string]string{"device_index": "0"}},
{Time: "2026-06-18T00:00:00Z", Value: publicMetricValue(80), Count: 2, Tags: map[string]string{"device_index": "1"}},
{Time: "2026-06-18T00:01:00Z", Value: publicMetricValue(20), Count: 2, Tags: map[string]string{"device_index": "0"}},
},
}
got := splitPublicMetricSeries(base)
if len(got) != 2 {
t.Fatalf("expected 2 tag series, got %d: %#v", len(got), got)
}
if got[0].Tags["device_index"] != "0" || got[0].Count != 2 {
t.Fatalf("unexpected first series: %#v", got[0])
}
if got[0].Tag["device_index"] != "0" {
t.Fatalf("tag alias was not set on first series: %#v", got[0])
}
if got[1].Tags["device_index"] != "1" || got[1].Count != 1 {
t.Fatalf("unexpected second series: %#v", got[1])
}
if got[0].Points[0].Tags["device_index"] != "0" || got[1].Points[0].Tags["device_index"] != "1" {
t.Fatalf("point tags were not preserved: %#v", got)
}
if got[0].Points[0].Tag["device_index"] != "0" || got[1].Points[0].Tag["device_index"] != "1" {
t.Fatalf("point tag aliases were not preserved: %#v", got)
}
}
func TestPublicMetricJSONIncludesTagAlias(t *testing.T) {
payload, err := json.Marshal(publicMetricSeries{
MetricKey: "ping.loss",
EntityID: "node-a",
Tag: map[string]string{"task_id": "7"},
Tags: map[string]string{"task_id": "7"},
Points: []publicMetricPoint{{
Time: "2026-06-18T00:00:00Z",
Value: publicMetricValue(0),
Tag: map[string]string{"task_id": "7"},
Tags: map[string]string{"task_id": "7"},
}},
})
if err != nil {
t.Fatalf("marshal series: %v", err)
}
text := string(payload)
if !strings.Contains(text, `"tag":{"task_id":"7"}`) {
t.Fatalf("series tag alias missing: %s", text)
}
if !strings.Contains(text, `"tags":{"task_id":"7"}`) {
t.Fatalf("series tags missing: %s", text)
}
}
func TestPublicMetricFillEmptyEmitsNullForEmptyBuckets(t *testing.T) {
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
points, err := metric.AggregatePoints([]metric.Point{
{MetricName: "cpu.usage", EntityID: "node-a", Timestamp: base, Value: 10},
{MetricName: "cpu.usage", EntityID: "node-a", Timestamp: base.Add(2 * time.Minute), Value: 30},
}, metric.AggregateQuery{
Query: metric.Query{
MetricName: "cpu.usage",
EntityID: "node-a",
Start: base,
End: base.Add(2 * time.Minute),
},
Aggregation: metric.AggAvg,
Interval: time.Minute,
FillEmpty: true,
})
if err != nil {
t.Fatalf("aggregate points: %v", err)
}
if len(points) != 3 {
t.Fatalf("expected 3 buckets with fill enabled, got %d: %#v", len(points), points)
}
if points[1].Count != 0 {
t.Fatalf("middle bucket should be empty, got %#v", points[1])
}
payload, err := json.Marshal(publicMetricPoint{
Time: points[1].Bucket.Format(time.RFC3339Nano),
Value: publicAggregateMetricValue(points[1], true),
Count: points[1].Count,
})
if err != nil {
t.Fatalf("marshal point: %v", err)
}
if !strings.Contains(string(payload), `"value":null`) {
t.Fatalf("empty bucket should serialize as null, got %s", payload)
}
}
func TestPublicMetricFillEmptyDisabledKeepsOnlyExistingBuckets(t *testing.T) {
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
points, err := metric.AggregatePoints([]metric.Point{
{MetricName: "cpu.usage", EntityID: "node-a", Timestamp: base, Value: 10},
{MetricName: "cpu.usage", EntityID: "node-a", Timestamp: base.Add(2 * time.Minute), Value: 30},
}, metric.AggregateQuery{
Query: metric.Query{
MetricName: "cpu.usage",
EntityID: "node-a",
Start: base,
End: base.Add(2 * time.Minute),
},
Aggregation: metric.AggAvg,
Interval: time.Minute,
})
if err != nil {
t.Fatalf("aggregate points: %v", err)
}
if len(points) != 2 {
t.Fatalf("expected only existing buckets by default, got %d: %#v", len(points), points)
}
for _, point := range points {
if publicAggregateMetricValue(point, false) == nil {
t.Fatalf("non-fill query should keep numeric values: %#v", point)
}
}
}
func TestPublicPingMetricFillEmptyMapsMinusOneToNull(t *testing.T) {
for _, metricName := range []string{metricstore.MetricPingLatency, metricstore.MetricPingLoss} {
if value := publicRawMetricValue(metricName, -1, true); value != nil {
t.Fatalf("raw %s -1 should become null when fill_empty is enabled, got %v", metricName, *value)
}
}
if value := publicAggregateMetricValue(metric.AggregatePoint{
MetricName: metricstore.MetricPingLatency,
Value: -1,
Count: 1,
}, true); value != nil {
t.Fatalf("downsampled ping -1 should become null when fill_empty is enabled, got %v", *value)
}
}
func TestPublicPingMetricMinusOneIsPreservedWithoutFillEmpty(t *testing.T) {
value := publicRawMetricValue(metricstore.MetricPingLatency, -1, false)
if value == nil || *value != -1 {
t.Fatalf("raw ping -1 should be preserved when fill_empty is disabled, got %v", value)
}
nonPing := publicRawMetricValue("temperature", -1, true)
if nonPing == nil || *nonPing != -1 {
t.Fatalf("negative values from non-ping metrics must be preserved, got %v", nonPing)
}
}
func TestPublicPingStatsFromAggregateGroupsUsesTaskNamesAndLossMetric(t *testing.T) {
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
taskMap := map[string]models.PingTask{
"1": {Id: 1, Name: "Tokyo ICMP", Type: "icmp", Interval: 60},
}
groups := publicPingMetricAggregateGroups{
Avg: map[string][]metric.AggregatePoint{
"1": {
{Bucket: base, Count: 2, Value: 20},
{Bucket: base.Add(time.Minute), Count: 2, Value: 40},
},
},
Min: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 12}},
},
Max: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 92}},
},
Last: map[string][]metric.AggregatePoint{
"1": {{Bucket: base.Add(time.Minute), Count: 1, Value: 44}},
},
P50: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 30}},
},
P99: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 80}},
},
StdDev: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 8}},
},
Loss: map[string][]metric.AggregatePoint{
"1": {{Bucket: base, Count: 4, Value: 0.25}},
},
LossAvailable: true,
}
stats := publicPingStatsFromAggregateGroups("node-a", groups, taskMap, nil)
if len(stats) != 1 {
t.Fatalf("expected one stat, got %#v", stats)
}
got := stats[0]
if got.Name != "Tokyo ICMP" || got.Type != "icmp" || got.Interval != 60 {
t.Fatalf("task metadata not applied: %#v", got)
}
if got.Total != 4 || got.Valid != 3 {
t.Fatalf("unexpected totals: %#v", got)
}
if got.Loss != 25 || got.LossApproximate {
t.Fatalf("loss should come from ping.loss metric: %#v", got)
}
if got.Min == nil || *got.Min != 12 || got.Max == nil || *got.Max != 92 || got.Avg == nil || *got.Avg != 30 {
t.Fatalf("latency stats mismatch: %#v", got)
}
if math.Abs(got.P99P50Ratio-1.6666666666666667) > 0.000001 {
t.Fatalf("unexpected volatility ratio: %#v", got)
}
}
func TestPublicPingMetricStatsIncludesZeroVolatility(t *testing.T) {
payload, err := json.Marshal(publicPingMetricTaskStats{
EntityID: "node-a",
TaskID: "1",
Total: 1,
Valid: 1,
P99P50Ratio: 0,
})
if err != nil {
t.Fatalf("marshal ping stats: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("unmarshal ping stats: %v", err)
}
if value, ok := decoded["p99_p50_ratio"]; !ok || value != float64(0) {
t.Fatalf("zero volatility must be present, got %s", payload)
}
}
func TestMetricDownsampleIntervalFloorsToStandardInterval(t *testing.T) {
got := metricDownsampleInterval(30*24*time.Hour, 500)
if got != time.Hour {
t.Fatalf("30d/500 should floor to 1h, got %s", got)
}
got = metricDownsampleInterval(time.Hour, 500)
if got != 5*time.Second {
t.Fatalf("1h/500 should floor to 5s, got %s", got)
}
}
+53 -18
View File
@@ -9,32 +9,57 @@ import (
"github.com/komari-monitor/komari/pkg/config"
)
func CorsMiddleware(initialEnabled bool, initialAllowedOrigins string) gin.HandlerFunc {
var mu sync.RWMutex
enabled := initialEnabled
allowedOrigins := initialAllowedOrigins
// CorsController 保存 CORS 中间件的可热更新状态。
//
// 相比过去把 config.Subscribe 直接埋在中间件闭包里,这里把「当前配置」抽成显式对象:
// - 需要热更新的状态集中在 controller 上,由外部(启动生命周期里的 reload 管理器)
// 统一调用 Update,避免订阅逻辑散落在中间件内部。
// - Middleware() 只负责读取当前状态并执行 CORS 校验,不再自行订阅配置事件。
type CorsController struct {
mu sync.RWMutex
enabled bool
allowedOrigins string
}
config.Subscribe(func(event config.ConfigEvent) {
mu.Lock()
defer mu.Unlock()
if ok, t := config.IsChangedT[bool](event, config.CorsOriginCheckEnabledKey); ok {
enabled = t
}
if ok, t := config.IsChangedT[string](event, config.CorsAllowedOriginsKey); ok {
allowedOrigins = t
}
})
// NewCorsController 使用初始配置构造一个 CORS 控制器。
func NewCorsController(enabled bool, allowedOrigins string) *CorsController {
return &CorsController{
enabled: enabled,
allowedOrigins: allowedOrigins,
}
}
// Update 根据配置事件刷新 CORS 相关状态。返回是否有字段发生变化。
func (ctrl *CorsController) Update(event config.ConfigEvent) bool {
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
changed := false
if ok, t := config.IsChangedT[bool](event, config.CorsOriginCheckEnabledKey); ok {
ctrl.enabled = t
changed = true
}
if ok, t := config.IsChangedT[string](event, config.CorsAllowedOriginsKey); ok {
ctrl.allowedOrigins = t
changed = true
}
return changed
}
func (ctrl *CorsController) snapshot() (bool, string) {
ctrl.mu.RLock()
defer ctrl.mu.RUnlock()
return ctrl.enabled, ctrl.allowedOrigins
}
// Middleware 返回读取当前控制器状态的 gin 中间件。
func (ctrl *CorsController) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !isAPIRequestPath(c.Request.URL.Path) {
c.Next()
return
}
mu.RLock()
corsEnabled := enabled
corsAllowedOrigins := allowedOrigins
mu.RUnlock()
corsEnabled, corsAllowedOrigins := ctrl.snapshot()
if !corsEnabled {
c.Next()
@@ -84,6 +109,16 @@ func CorsMiddleware(initialEnabled bool, initialAllowedOrigins string) gin.Handl
}
}
// CorsMiddleware 保留原有签名:内部自建 controller 并自行订阅配置事件。
// 供测试以及不接入集中式 reload 管理器的场景使用。
func CorsMiddleware(initialEnabled bool, initialAllowedOrigins string) gin.HandlerFunc {
ctrl := NewCorsController(initialEnabled, initialAllowedOrigins)
config.Subscribe(func(event config.ConfigEvent) {
ctrl.Update(event)
})
return ctrl.Middleware()
}
func isAPIRequestPath(path string) bool {
return path == "/api" || strings.HasPrefix(path, "/api/")
}