Compare commits

..
22 Commits
Author SHA1 Message Date
Akizon77 6ab9c6724e refactor: use GetAllPingTasks in ReloadPingSchedule and add ordering test 2026-07-22 16:14:00 +08:00
Akizon77 6efc72ce31 refactor: remove automatic metric store startup migration; mandate manual migration
Replace the startup migration (which previously ran automatically on every start if the target fingerprint changed) with a manual migration flow that administrators trigger explicitly via upgrade wizard or WebUI/API. Adjust comments across dbcore, metricstore, and migration_store to reflect this shift. Update RecoverStore to no longer copy data or trigger startup migration; it now only records the current manual-migration source. Add a helper function `targetFingerprint` for tracking. Remove the test assertion that recovery should be a no‑op for startup migration, renaming the test accordingly. This change gives administrators full control over cross‑store data migration and reduces complexity in the startup path.
2026-07-22 15:26:36 +08:00
Akizon77 1f6bd1391a refactor: relocate config/migrations to internal, rename corn to scheduler 2026-07-22 14:39:42 +08:00
Akizon77 5b93181d94 feat(metric): add metric store recovery mode with retry and temporary HTTP server
When the metric store fails to initialize, the server now enters a recovery
mode: it retries the connection up to 3 times with 5-second intervals, and
if all attempts fail, starts a temporary HTTP server that displays
recovery instructions (e.g., configure or fix the database). This avoids
a hard startup failure and gives users a guided path to resolve
connectivity issues.

Refactors the store initialization into a retryable `ConnectMetricStore`
method, registers cleanup only once via `metricStoreCleanupAdded`, and
redacts sensitive connection details (e.g., DSN password) from error
messages.
2026-07-22 14:01:42 +08:00
Akizon77 f07bd749c4 fix: format event time in local timezone instead of UTC
The `{{time}}` template placeholder previously always
formatted event times as UTC.  This change uses the
server's local timezone (`time.Local`) so that timestamps
in messages reflect the expected local time.

The test was updated to verify the local-time formatting
by temporarily overriding `time.Local` during the test.
2026-07-21 23:46:29 +08:00
Akizon77 887381ad4f feat(metricstore): batch ping records for improved performance
Refactor WritePingRecord to enqueue ping records to a batch worker if available, and add a new ping queue and batching logic to the report batcher. This reduces the number of individual database writes by grouping ping records into batches, similar to how reports are batched.
2026-07-21 23:40:59 +08:00
Akizon77 11617b46b3 perf(metric): skip digests for non-percentile rollup queries 2026-07-21 18:14:05 +08:00
Akizon77 5a8488926c feat: first-run installation guide 2026-07-21 13:57:01 +08:00
Akizon77 db2ed8e424 refactor: replace stdlib log with custom logger, enforce gin release mode 2026-07-21 11:11:16 +08:00
Akizon77 312834f921 feat: add theme market source management and archive validation
Added ThemeMarketSourcesKey constant. Implemented validateThemeArchive function to enforce limits on file count, individual file size, and total extracted size. Added manifest size limit check via io.LimitReader. Extended admin API with market source routes (list, create, update, delete) and market catalog/install routes.
2026-07-20 23:05:19 +08:00
Akizon77 9d149ab660 ci: add workflow to rebuild existing releases 2026-07-20 21:17:59 +08:00
Akizon77 9d7e24ab78 fix(ci): promote Docker latest explicitly 2026-07-20 21:10:02 +08:00
Akizon77 99c684e75e fix(ci): build maintenance releases with matching frontend 2026-07-20 21:00:04 +08:00
Akizon77 a402cc6189 fix(ci): only tag Docker latest for latest release 2026-07-20 20:43:39 +08:00
Akizon77 0bac110d8a feat: use aligned, unbuffered I/O for disk write benchmark
Replace buffered file writes with direct I/O (O_DIRECT) by opening the benchmark file through an unbuffered path.
- Allocate memory-aligned blocks to satisfy direct I/O requirements.
- Encode the random state into the block using `binary.LittleEndian.PutUint64`.
- Move the `Sync()` call to after the write loop to improve throughput.
- Add a test to verify the new benchmark behavior and cleanup of temporary files.

This change ensures the benchmark measures raw disk performance without caching effects.
2026-07-20 18:57:08 +08:00
Akizon77 7a955467fd feat: migrate legacy monitoring to hourly P95 aggregation
Replace per-row metric point conversion with hourly P95 aggregation to reduce point count and align with the metric store design. Legacy rows (records, gpu_records, ping_records) are now collapsed into single hourly P95 values per metric, entity, and tag set.

- Remove legacyRecordRow, legacyGPURecordRow, legacyPingRecordRow structs.
- Add legacyP95Group and legacyHourlyP95Aggregator for grouping and writing P95 points.
- Update InspectLegacyMonitoring to estimate points via new hourly offset logic.
- Refactor migrateLegacyRecordTables to use the aggregator with dynamic column projection and missing column validation.
- Introduce legacyMonitoringInterval (1 hour) for bucket truncation.
2026-07-20 18:08:15 +08:00
Akizon77 eb7ecc6fe4 fix(resourceprobe): cast Totalram to uint64 to avoid overflow 2026-07-20 17:25:34 +08:00
Akizon77 f59e0e813c feat: add automatic low resource mode detection and SQLite tuning
Detect system resources on first startup to determine low resource mode. Apply
appropriate SQLite pragmas (cache size, temp store, synchronous, mmap) and
handle report queue overflow by dropping batches when low resource mode is
active.
2026-07-20 17:18:06 +08:00
Akizon77 b4d3977871 feat: make metric deletion async and add UpdateMetricRetention 2026-07-20 16:44:33 +08:00
Akizon77 b55436842f feat: drop obsolete builtin metrics and expose definition creation 2026-07-20 15:44:22 +08:00
Akizon77 cdfd5bd13f fix: remove orphaned metric data before space reclaim and reject unknown metrics 2026-07-20 15:30:18 +08:00
Akizon77 5a66f28e81 fix: #609 #612 continue compaction on per-metric errors and add separate compaction gate 2026-07-20 13:13:54 +08:00
126 changed files with 5304 additions and 1672 deletions

No files matched your search

+8 -1
View File
@@ -8,6 +8,9 @@ inputs:
repository:
description: komari-web repository URL.
default: https://github.com/komari-monitor/komari-web
ref:
description: Optional komari-web ref to build. Uses the repository default branch when empty.
default: ""
artifact-name:
description: Optional artifact name for the packed frontend theme.
default: ""
@@ -25,7 +28,11 @@ runs:
run: |
set -euxo pipefail
rm -rf komari-web
git clone --depth=1 "${{ inputs.repository }}" komari-web
if [ -n "${{ inputs.ref }}" ]; then
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
else
git clone --depth=1 "${{ inputs.repository }}" komari-web
fi
cd komari-web
npm install
npm run build
+159
View File
@@ -0,0 +1,159 @@
name: Rebuild Existing Release
on:
workflow_dispatch:
inputs:
release_tag:
description: Existing GitHub Release tag whose assets and version image will be replaced.
required: true
type: string
server_ref:
description: Server commit, branch, or tag to build.
required: true
type: string
frontend_ref:
description: komari-web commit, branch, or tag to build.
required: true
type: string
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout workflow source
uses: actions/checkout@v4
- name: Build selected frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ inputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Restore frontend artifact
uses: komari-monitor/komari/.github/actions/restore-frontend@main
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
uses: komari-monitor/komari/.github/actions/setup-zig@main
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
VERSION_HASH="$(git rev-parse HEAD)"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
- name: Upload binary to release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
build-and-push-docker:
needs: build-and-attach-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push version Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
no-cache: true
provenance: false
-1
View File
@@ -107,7 +107,6 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.inputs.version }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
+38 -1
View File
@@ -13,7 +13,24 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
resolve-release:
runs-on: ubuntu-latest
outputs:
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
steps:
- id: release
env:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
build-frontend:
needs: resolve-release
runs-on: ubuntu-latest
steps:
@@ -24,6 +41,7 @@ jobs:
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
@@ -155,6 +173,20 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check whether this is the latest GitHub release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: |
is_latest="$(gh api graphql \
-f 'query=query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { isLatest } } }' \
-F owner="${{ github.repository_owner }}" \
-F name="${{ github.event.repository.name }}" \
-F tag="$TAG" \
--jq '.data.repository.release.isLatest')"
echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -162,7 +194,6 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
@@ -174,3 +205,9 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
- name: Promote Docker latest tag
if: steps.release.outputs.is_latest == 'true'
env:
TAG: ${{ github.event.release.tag_name }}
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
+2 -9
View File
@@ -58,14 +58,7 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. View the default username and password:
```bash
docker logs komari
```
4. Access `http://<your_server_ip>:25774` in your browser.
> [!NOTE]
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
3. Access `http://<your_server_ip>:25774` in your browser and complete the installation guide. The guide creates the administrator account and configures the site metadata and monitoring database.
### 3. Binary File Deployment
@@ -75,7 +68,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
4. Complete the installation guide to create the administrator account and configure the site metadata and monitoring database.
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
-498
View File
@@ -1,498 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/pkg/migrations"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
upgradeweb "github.com/komari-monitor/komari/web/update"
)
// cleanupFunc 是一个关闭阶段执行的清理函数。
type cleanupFunc struct {
name string
fn func(ctx context.Context) error
}
// App 显式建模服务端的启动生命周期。
//
// 过去 RunServer 把目录创建、数据库、metric store、GeoIP、定时任务、通知、OAuth、
// Gin 中间件、路由、HTTP 启动与 shutdown 全部混在一个函数里,
// 启动顺序只能靠通读整段代码推断,异步初始化过早且吞掉错误,关闭也不完整。
//
// App 把这些拆成有序阶段:
//
// Bootstrap 基础设施:目录、数据库、默认管理员、配置快照
// InitStores 存储:metric store
// InitProviders 外部 provider:OAuth(同步,路由依赖)、GeoIP、消息发送
// StartBackground 后台:定时任务
// BuildRouter 构建 Gin 引擎与路由
// Run 启动 HTTP 服务并阻塞直到收到信号
// Shutdown 反序执行已登记的清理函数
//
// 每个阶段返回错误即可让上层决定是否中止启动;各资源在创建时把对应清理登记到
// cleanup 栈,关闭时按后进先出(LIFO)顺序释放。
type App struct {
settings *config.Settings
engine *gin.Engine
server *http.Server
reload *ReloadManager
dbReady bool
oauthReady bool
cleanups []cleanupFunc
}
// NewApp 构造一个空的 App。真正的初始化在各阶段方法中完成。
func NewApp() *App {
return &App{
reload: NewReloadManager(),
}
}
// addCleanup 登记一个关闭阶段的清理函数(后进先出执行)。
func (a *App) addCleanup(name string, fn func(ctx context.Context) error) {
a.cleanups = append(a.cleanups, cleanupFunc{name: name, fn: fn})
}
// Bootstrap 初始化基础设施:数据目录、数据库、默认管理员账号、配置快照。
func (a *App) Bootstrap() error {
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
return fmt.Errorf("failed to create theme directory: %w", err)
}
// 注入版本标识,供 dbcore 在检测到版本升级时自动备份 ./data。
// 必须在 Initialize() 之前设置。
dbcore.SetVersionID(utils.CurrentVersion + "-" + utils.VersionHash)
// 显式初始化数据库(返回错误而非在 getter 里 log.Fatal)。
if err := dbcore.Initialize(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
a.dbReady = true
a.addCleanup("database", func(context.Context) error {
return dbcore.Close()
})
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
// 首次启动创建默认管理员账号。
if err := ensureDefaultAdmin(); err != nil {
return fmt.Errorf("failed to ensure default admin account: %w", err)
}
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
a.settings = conf
return nil
}
// InitStores 初始化独立存储组件(metric store)并执行 metrics 迁移。
//
// metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃):
// 未显式配置时使用 SQLite(./data/metrics.db),否则使用配置的 MySQL/PostgreSQL。
// 初始化失败即启动失败,不再静默 fallback 到旧 records 表。
//
// 初始化成功后先执行需要 metric store 的一次性迁移,再执行启动迁移:当 metrics
// 存储后端发生变化(例如从默认 SQLite 切换到 MySQL/PostgreSQL)时,把上一个
// metrics 目标库的数据搬运到当前目标。迁移失败同样让启动失败,并打印明确错误。
func (a *App) InitStores() error {
if err := metricstore.InitializeStore(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize metric store: %v", err))
return fmt.Errorf("failed to initialize metric store: %w", err)
}
a.addCleanup("metric-store", func(ctx context.Context) error {
return metricstore.CloseStoreContext(ctx)
})
if err := migrations.RunMetricStoreMigrations(migrations.MetricContext{
DB: dbcore.GetDBInstance(),
Store: metricstore.GetStore(),
}); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Metric store one-shot migrations failed: %v", err))
return fmt.Errorf("metric store one-shot migrations failed: %w", err)
}
// 存储后端切换时把上一个 metrics 目标库的数据搬运到当前目标(失败即启动失败)。
if err := metricstore.RunStartupMigration(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Metrics startup migration failed: %v", err))
return fmt.Errorf("metrics startup migration failed: %w", err)
}
metricstore.StartReportBatcher()
a.addCleanup("metric-report-batcher", func(ctx context.Context) error {
return metricstore.StopReportBatcher(ctx)
})
return nil
}
// InitProviders 初始化外部 provider。
//
// OAuth 必须在 HTTP 服务开始接收请求之前同步完成,否则 oauth.CurrentProvider()
// 存在空指针风险;GeoIP 与消息发送允许后台初始化。
func (a *App) InitProviders() error {
a.initOAuth()
// GeoIP:可能涉及下载/加载 mmdb,放后台执行避免拖慢启动。
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error {
return geoip.Shutdown()
})
// 消息发送 provider。
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error {
return messageSender.Shutdown()
})
return nil
}
// initOAuth initializes the provider once. The restricted upgrade server also
// needs OAuth login, so provider setup may happen before the normal provider
// stage without registering duplicate cleanup work.
func (a *App) initOAuth() {
if a.oauthReady {
return
}
if err := oauth.Initialize(); err != nil {
// Keep password login available when an OAuth provider is misconfigured.
log.Printf("Failed to initialize OAuth provider: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
}
a.oauthReady = true
a.addCleanup("oauth", func(context.Context) error {
return oauth.Shutdown()
})
}
func (a *App) LegacyUpgradeRequired() (bool, migrations.LegacyMonitoringSummary, error) {
return migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
}
// RunLegacyUpgrade starts a restricted HTTP server on the normal listen
// address. It returns only after the migration finishes and the restricted
// listener has released the port, or after shutdown/interruption.
func (a *App) RunLegacyUpgrade(summary migrations.LegacyMonitoringSummary) (bool, error) {
a.initOAuth()
controller := upgradeweb.NewController(dbcore.GetDBInstance(), summary)
controller.Activate()
defer controller.Deactivate()
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware())
r.Use(api.IdentityMiddleware())
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
controller.Register(r)
frontendpublic.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(func(c *gin.Context) {
requestPath := c.Request.URL.Path
if strings.HasPrefix(requestPath, "/api") {
api.RespondError(c, http.StatusNotFound, "Not found in upgrade mode")
return
}
if c.Request.Method == http.MethodGet && requestPath != upgradeweb.PagePath && filepath.Ext(requestPath) == "" {
c.Redirect(http.StatusTemporaryRedirect, upgradeweb.PagePath)
return
}
for _, handler := range handlers {
handler(c)
if c.IsAborted() {
return
}
}
})
})
a.engine = r
a.server = &http.Server{Addr: flags.Listen, Handler: r}
serverErr := make(chan error, 1)
log.Printf("Legacy monitoring data requires the 1.2.7 upgrade wizard on %s", flags.Listen)
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case err := <-serverErr:
return false, fmt.Errorf("listen in upgrade mode: %w", err)
case <-quit:
return false, a.Shutdown()
case <-controller.Done():
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.server.Shutdown(ctx); err != nil {
return false, fmt.Errorf("stop upgrade server: %w", err)
}
a.server = nil
a.engine = nil
return true, nil
}
}
// StartBackground 启动后台工作:定时任务。
func (a *App) StartBackground() error {
registerScheduledWork()
a.addCleanup("scheduler", func(context.Context) error {
corn.StopAll()
return nil
})
return nil
}
// registerReloadHandlers 把此前散落各处的 config.Subscribe 统一登记到 reload 管理器。
func (a *App) registerReloadHandlers(cors *security.CorsController) {
// OAuth provider 切换。
a.reload.Register("oauth-provider", func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if t == "" || t == "none" {
t = "github"
}
oidcProvider, err := database.GetOidcConfigByName(t)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
return
}
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
if err := oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
})
// GeoIP provider 切换。
a.reload.Register("geoip-provider", func(event config.ConfigEvent) {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
})
// 消息发送方式切换。
a.reload.Register("message-sender", func(event config.ConfigEvent) {
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
// CORS 配置热更新。
a.reload.Register("cors", func(event config.ConfigEvent) {
cors.Update(event)
})
}
// BuildRouter 构建 Gin 引擎、中间件与全部路由,并登记热重载处理器。
func (a *App) BuildRouter() error {
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware())
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
router.Register(r)
// 集中登记并启动热重载订阅。
a.registerReloadHandlers(cors)
a.reload.Start()
a.engine = r
return nil
}
// Run 启动 HTTP 服务并阻塞直到收到中断信号或服务异常退出。
func (a *App) Run() error {
a.server = &http.Server{
Addr: flags.Listen,
Handler: a.engine,
}
serverErr := make(chan error, 1)
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
select {
case err := <-serverErr:
a.onFatal(err)
return fmt.Errorf("listen: %w", err)
case <-quit:
return a.Shutdown()
}
}
// Shutdown 优雅关闭:先停止接收新请求,再反序执行已登记的清理函数。
func (a *App) Shutdown() error {
if a.dbReady {
auditlog.Log("", "", "server is shutting down", "info")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 先关闭 HTTP 服务,停止接收新请求。
if a.server != nil {
if err := a.server.Shutdown(ctx); err != nil {
log.Printf("HTTP server forced to shutdown: %v", err)
}
}
// 反序释放资源(后进先出)。
for i := len(a.cleanups) - 1; i >= 0; i-- {
c := a.cleanups[i]
if err := c.fn(ctx); err != nil {
log.Printf("cleanup %q failed: %v", c.name, err)
}
}
return nil
}
// onFatal 处理 HTTP 服务致命错误:尽力释放已登记资源。
func (a *App) onFatal(err error) {
if a.dbReady {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for i := len(a.cleanups) - 1; i >= 0; i-- {
c := a.cleanups[i]
if cerr := c.fn(ctx); cerr != nil {
log.Printf("cleanup %q failed: %v", c.name, cerr)
}
}
}
// ensureDefaultAdmin 首次启动(无任何用户)时创建默认管理员账号。
func ensureDefaultAdmin() error {
var count int64
dbcore.GetDBInstance().Model(&models.User{}).Count(&count)
if count != 0 {
return nil
}
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
return err
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
return nil
}
// registerScheduledWork 注册所有定时任务与首启动同步逻辑。
func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
log.Println("Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
log.Println("Failed to reload load notification schedule:", err)
}
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
log.Println("Failed to add cleanup scheduled task:", err)
}
if err := corn.AddContextFunc("metrics:compact", "@every 5m", true, compactMetricStore); err != nil {
log.Println("Failed to add metric compact scheduled task:", err)
}
if err := corn.AddFunc("notifier:traffic", "@every 1m", notifier.CheckTraffic); err != nil {
log.Println("Failed to add traffic notification task:", err)
}
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
log.Println("Failed to add expire notification scheduled task:", err)
}
notifier.InitTrafficReportSchedule()
}
const taskResultRetentionDays = 30
func cleanupScheduledData() {
before := time.Now().UTC().Add(-24 * time.Hour * taskResultRetentionDays)
if err := tasks.ClearTaskResultsByTimeBefore(before); err != nil {
log.Printf("Failed to clean expired task results: %v", err)
}
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func compactMetricStore(ctx context.Context) {
compactCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
written, err := metricstore.Compact(compactCtx, time.Now().UTC())
if errors.Is(err, metricstore.ErrCompactInProgress) {
return
}
if err != nil {
log.Printf("Failed to compact metric store after writing %d rollup buckets: %v", written, err)
return
}
if written > 0 {
log.Printf("Metric store compacted %d rollup buckets", written)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/spf13/cobra"
)
+42 -12
View File
@@ -1,7 +1,8 @@
package cmd
import (
"log"
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -25,32 +26,61 @@ func init() {
// RunServer 按显式的生命周期阶段启动服务端。
//
// 具体各阶段的职责与顺序见 App(cmd/app.go)。这里只负责串联:
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
app := NewApp()
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
if err := app.Bootstrap(); err != nil {
_ = app.Shutdown()
log.Fatalf("server startup failed at %q: %v", "bootstrap", err)
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
}
required, summary, err := app.LegacyUpgradeRequired()
installRequired, err := app.InstallRequired()
if err != nil {
_ = app.Shutdown()
log.Fatalf("server startup failed at %q: %v", "detect-1.2.7-upgrade", err)
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", err)
}
if required {
completed, err := app.RunLegacyUpgrade(summary)
if installRequired {
completed, err := app.RunInstallGuide()
if err != nil {
_ = app.Shutdown()
log.Fatalf("server startup failed at %q: %v", "run-1.2.7-upgrade", err)
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
}
if !completed {
return
}
}
// 初始化阶段:任一步失败都不应继续对外服务。
required, summary, err := app.LegacyUpgradeRequired()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "detect-1.2.7-upgrade", err)
}
if required {
completed, err := app.RunLegacyUpgrade(summary)
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-1.2.7-upgrade", err)
}
if !completed {
return
}
}
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
if err := app.ConnectMetricStoreWithRetry(); err != nil {
completed, recoveryErr := app.RunMetricStoreRecovery(err)
if recoveryErr != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
}
if !completed {
return
}
}
// 其余初始化阶段:任一步失败都不应继续对外服务。
type stage struct {
name string
fn func() error
@@ -65,11 +95,11 @@ func RunServer() {
if err := s.fn(); err != nil {
// 已登记的资源尽力回收,再退出。
_ = app.Shutdown()
log.Fatalf("server startup failed at %q: %v", s.name, err)
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
}
}
if err := app.Run(); err != nil {
log.Fatalf("server exited with error: %v", err)
logger.Fatalf("server", "server exited with error: %v", err)
}
}
+10 -39
View File
@@ -4,14 +4,12 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"time"
"github.com/google/uuid"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
const constantSalt = "06Wm4Jv1Hkxx"
@@ -56,7 +54,10 @@ func hashPasswd(passwd string) string {
}
func CreateAccount(username, passwd string) (user models.User, err error) {
db := dbcore.GetDBInstance()
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
}
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
hashedPassword := hashPasswd(passwd)
user = models.User{
UUID: uuid.New().String(),
@@ -71,7 +72,10 @@ func CreateAccount(username, passwd string) (user models.User, err error) {
}
func DeleteAccountByUsername(username string) (err error) {
db := dbcore.GetDBInstance()
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
}
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
err = db.Where("username = ?", username).Delete(&models.User{}).Error
if err != nil {
return err
@@ -79,39 +83,6 @@ func DeleteAccountByUsername(username string) (err error) {
return nil
}
// 创建默认管理员账户,使用环境变量 ADMIN_USERNAME 作为用户名,环境变量 ADMIN_PASSWORD 作为密码
func CreateDefaultAdminAccount() (username, passwd string, err error) {
db := dbcore.GetDBInstance()
username = os.Getenv("ADMIN_USERNAME")
if username == "" {
username = "admin"
}
passwd = os.Getenv("ADMIN_PASSWORD")
if passwd == "" {
passwd = utils.GeneratePassword()
}
hashedPassword := hashPasswd(passwd)
user := models.User{
UUID: uuid.New().String(),
Username: username,
Passwd: hashedPassword,
SSOID: "",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
err = db.Create(&user).Error
if err != nil {
return "", "", err
}
return username, passwd, nil
}
func GetUserByUUID(uuid string) (user models.User, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&user).Error
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"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/internal/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
+5 -3
View File
@@ -1,7 +1,7 @@
package auditlog
import (
"log"
logger "github.com/komari-monitor/komari/utils/log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
@@ -17,7 +17,9 @@ func Log(ip, uuid, message, msgType string) {
MsgType: msgType,
Time: time.Now().UTC(),
}
db.Create(logEntry)
if err := db.Create(logEntry).Error; err != nil {
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
}
}
func EventLog(eventType, message string) {
@@ -29,6 +31,6 @@ func RemoveOldLogs() {
db := dbcore.GetDBInstance()
threshold := time.Now().UTC().AddDate(0, 0, -30)
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
log.Println("Failed to remove old logs:", err)
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
}
}
+3 -10
View File
@@ -1,15 +1,13 @@
package clients
import (
"context"
"encoding/json"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"math"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
@@ -18,11 +16,6 @@ import (
)
func DeleteClient(clientUuid string) error {
// Clean the independent metric store before removing the client from the main DB.
if err := metricstore.DeleteEntity(context.Background(), clientUuid); err != nil {
return err
}
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
@@ -147,7 +140,7 @@ func CreateClient() (clientUUID, token string, err error) {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -172,7 +165,7 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
+55 -27
View File
@@ -4,7 +4,6 @@ import (
"archive/zip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
@@ -13,9 +12,9 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/migrations"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
logger "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -241,17 +240,17 @@ func backupOnVersionUpgrade() {
}
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[upgrade-backup] failed to create backup dir: %v", err)
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
return
}
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("upgrade-%s.zip", tsName))
backupZipPath := filepath.Join(".", "data", "backup.zip")
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
logger.Errorf("dbcore", "[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)
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
writeVersionMarker()
}
@@ -259,7 +258,7 @@ func backupOnVersionUpgrade() {
// writeVersionMarker 将当前 versionID 写入配置库。
func writeVersionMarker() {
if err := config.Set(SystemVersionKey, versionID); err != nil {
log.Printf("[upgrade-backup] failed to persist version marker: %v", err)
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
}
}
@@ -300,7 +299,7 @@ func Initialize() error {
// 需要错误处理的启动流程应优先调用 Initialize()。
func GetDBInstance() *gorm.DB {
if err := Initialize(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
}
return instance
}
@@ -326,40 +325,40 @@ func doInitialize() error {
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)
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
logger.Infof("dbcore", "[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)
logger.Errorf("dbcore", "[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)
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
logger.Infof("dbcore", "[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)
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
logger.Infof("dbcore", "[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)
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
}
}
}()
@@ -372,7 +371,7 @@ func doInitialize() error {
}
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
Logger: logger.NewGormLogger(),
NowFunc: func() time.Time { return time.Now().UTC() },
}
@@ -394,7 +393,6 @@ func doInitialize() error {
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 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
@@ -403,11 +401,11 @@ func doInitialize() error {
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
} else {
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
logger.Errorf("dbcore", "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)
logger.Errorf("dbcore", "Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("PRAGMA synchronous = NORMAL;")
instance.Exec("PRAGMA busy_timeout = 5000;")
@@ -422,7 +420,7 @@ func doInitialize() error {
}
config.SetDb(instance)
// 配置库就绪后、执行后续 AutoMigrate 与一次性 metrics 迁移之前:
// 配置库就绪后、执行后续 AutoMigrate 之前:
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
backupOnVersionUpgrade()
@@ -431,7 +429,7 @@ func doInitialize() error {
// 注意:负载/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 包中。
@@ -454,14 +452,44 @@ func doInitialize() error {
if err := instance.AutoMigrate(
&models.Session{},
); err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
logger.Errorf("dbcore", "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)
logger.Errorf("dbcore", "Failed to create Task and TaskResult table, it may already exist: %v", err)
}
return nil
}
// ConfigureLowResourceMode updates connection-local SQLite memory settings.
// The main database uses one connection, so these PRAGMAs remain effective for
// the lifetime of the pool and can be switched without reopening the database.
func ConfigureLowResourceMode(enabled bool) error {
if instance == nil || flags.ApplyDatabaseTypeNormalization() != flags.DatabaseTypeSQLite {
return nil
}
pragmas := []string{
"PRAGMA synchronous = NORMAL;",
"PRAGMA mmap_size = 0;",
}
if enabled {
pragmas = append(pragmas,
"PRAGMA cache_size = -8192;",
"PRAGMA temp_store = FILE;",
)
} else {
pragmas = append(pragmas,
"PRAGMA cache_size = -65536;",
"PRAGMA temp_store = MEMORY;",
)
}
for _, pragma := range pragmas {
if err := instance.Exec(pragma).Error; err != nil {
return fmt.Errorf("apply SQLite resource setting %q: %w", pragma, err)
}
}
return nil
}
+10 -1
View File
@@ -62,8 +62,17 @@ func TestReclaimSpaceReportsBusyStore(t *testing.T) {
if !errors.Is(result.BeforeSizeError, ErrStoreBusy) || !errors.Is(result.AfterSizeError, ErrStoreBusy) {
t.Fatalf("busy result should mark both measurements unavailable: %#v", result)
}
compactCtx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Compact(compactCtx, time.Now()); !errors.Is(err, context.Canceled) {
t.Fatalf("compact error = %v, want %v", err, context.Canceled)
}
if !compactOperations.TryAcquire() {
t.Fatal("acquire compact operation gate")
}
defer compactOperations.Release()
if _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
t.Fatalf("compact error = %v, want %v", err, ErrCompactInProgress)
t.Fatalf("overlapping compact error = %v, want %v", err, ErrCompactInProgress)
}
}
+8 -6
View File
@@ -8,13 +8,9 @@ const (
MetricGPUMemTotal = "gpu.memory.total"
MetricGPUTemp = "gpu.temperature"
MetricRAM = "memory.used"
MetricRAMTotal = "memory.total"
MetricSwap = "swap.used"
MetricSwapTotal = "swap.total"
MetricLoad = "load.average"
MetricTemp = "temperature"
MetricDisk = "disk.used"
MetricDiskTotal = "disk.total"
MetricNetIn = "net.in.rate"
MetricNetOut = "net.out.rate"
MetricNetTotalUp = "net.total.up"
@@ -31,12 +27,18 @@ const (
// loadRecordMetricNames are the entity-level metrics used to reconstruct the
// legacy Record response shape.
var loadRecordMetricNames = []string{
MetricCPU, MetricGPU, MetricRAM, MetricRAMTotal, MetricSwap, MetricSwapTotal,
MetricLoad, MetricTemp, MetricDisk, MetricDiskTotal, MetricNetIn, MetricNetOut,
MetricCPU, MetricGPU, MetricRAM, MetricSwap, MetricLoad, MetricDisk, MetricNetIn, MetricNetOut,
MetricNetTotalUp, MetricNetTotalDown, MetricTrafficUp, MetricTrafficDown,
MetricProcess, MetricConnections, MetricConnectionsUDP,
}
var obsoleteBuiltinMetricNames = []string{
"memory.total",
"swap.total",
"temperature",
"disk.total",
}
// gpuDeviceRecordMetricNames are stored separately from the entity-level GPU
// average and are included when deleting all system records.
var gpuDeviceRecordMetricNames = []string{
+230 -107
View File
@@ -4,24 +4,25 @@ import (
"context"
"errors"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"sort"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
)
var (
store *metric.Store
storeFingerprint string
storeMu sync.RWMutex
storeOnce sync.Once
storeOperations = newStoreOperationGate()
compactAt int
store *metric.Store
storeFingerprint string
storeMu sync.RWMutex
storeInitMu sync.Mutex
storeOperations = newStoreOperationGate()
compactOperations = newStoreOperationGate()
compactAt int
)
var ErrCompactInProgress = errors.New("metric store compact already in progress")
@@ -38,31 +39,35 @@ const (
// 注意:metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃)。
// 未显式配置时默认使用 SQLite(./data/metrics.db)。
type MetricStoreConfig struct {
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
DownsamplingEnabled bool `json:"metric_downsampling_enabled" default:"false"` // 是否启用分层降采样
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
LowResourceMode bool `json:"low_resource_mode"` // 低资源模式由首次探测或后台设置决定
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
}
// MetricStoreConfigKeys 配置键
//
// MetricStoreEnabledKey 已废弃:metric store 始终启用,保留常量仅用于清理旧配置。
const (
MetricStoreEnabledKey = "metric_store_enabled" // Deprecated: metric store 始终启用
MetricDBDriverKey = "metric_db_driver"
MetricDBDSNKey = "metric_db_dsn"
MetricDownsamplingEnabledKey = "metric_downsampling_enabled"
MetricTablePrefixKey = "metric_table_prefix"
MetricMaxOpenConnsKey = "metric_max_open_conns"
MetricMaxIdleConnsKey = "metric_max_idle_conns"
// MigrationTargetKey 记录上一次成功完成启动迁移的目标指纹(driver+dsn)。
// 当目标发生变化(例如从 SQLite 切换到 MySQL/PostgreSQL)时,启动迁移会
// 重新执行,把上一个目标库的数据搬运到新的目标 metrics 库。
MetricStoreEnabledKey = "metric_store_enabled" // Deprecated: metric store 始终启用
MetricDBDriverKey = "metric_db_driver"
MetricDBDSNKey = "metric_db_dsn"
MetricTablePrefixKey = "metric_table_prefix"
MetricMaxOpenConnsKey = "metric_max_open_conns"
MetricMaxIdleConnsKey = "metric_max_idle_conns"
// MigrationTargetKey 记录上一次成功完成手动迁移的目标指纹(driver+dsn),
// 用于在下一次管理员手动迁移时推断默认源库。
MigrationTargetKey = "metric_migration_target"
)
func targetFingerprint(cfg *MetricStoreConfig) string {
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
dsn := strings.TrimSpace(cfg.DSN)
return fmt.Sprintf("%s|%s", driver, dsn)
}
// buildMetricConfig 根据 MetricStoreConfig 构造底层 metric.Config。
// autoMigrate 控制是否在 Open 时自动建表:正式初始化/热加载时为 true,
// 仅做连接测试时为 false(不写入 schema,避免对目标库产生副作用)。
@@ -77,9 +82,7 @@ func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config,
metric.WithTablePrefix(tablePrefix),
metric.WithAutoMigrate(autoMigrate),
}
if cfg.DownsamplingEnabled {
opts = append(opts, metric.WithRollupPolicy(defaultRollupPolicy()))
}
opts = append(opts, metric.WithRollupPolicy(defaultRollupPolicy()))
switch driver {
case metric.DriverSQLite:
@@ -99,11 +102,18 @@ func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config,
// 同时启用独立的 WAL 只读连接池提升前台查询并发(写仍走单主连接)。
// 这里刻意忽略 cfg.MaxOpenConns/MaxIdleConns —— 对 SQLite 而言多写连接
// 只会引入锁竞争而非提升吞吐。
opts = append(opts,
metric.WithMaxOpenConns(1),
metric.WithMaxIdleConns(1),
metric.WithSQLiteReadPool(4),
)
opts = append(opts, metric.WithMaxOpenConns(1), metric.WithMaxIdleConns(1))
if cfg.LowResourceMode {
opts = append(opts,
metric.WithSQLiteProfile(metric.SQLiteProfileBalanced),
metric.WithSQLiteCacheSizeKB(8*1024),
metric.WithSQLiteMMapSize(0),
metric.WithSQLiteTempStoreMemory(false),
metric.WithSQLiteReadPool(0),
)
} else {
opts = append(opts, metric.WithSQLiteReadPool(4))
}
return metric.SQLite(dsn, opts...), nil
case metric.DriverMySQL:
opts = append(opts,
@@ -309,36 +319,97 @@ func TestConnection(ctx context.Context, cfg *MetricStoreConfig) error {
return s.Ping(ctx)
}
// InitializeStore 初始化 metric store(启动时调用,仅执行一次)。
// InitializeStore 初始化 metric store(启动时调用,可在失败后重试)。
func InitializeStore() error {
var initErr error
storeOnce.Do(func() {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
initErr = fmt.Errorf("failed to load metric store config: %w", err)
return
storeInitMu.Lock()
defer storeInitMu.Unlock()
// A previous failed connection must remain retryable. The old sync.Once
// implementation consumed the one-time call on failure and returned nil on
// every later call while the store was still nil.
storeMu.RLock()
initialized := store != nil
storeMu.RUnlock()
if initialized {
return nil
}
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return fmt.Errorf("failed to load metric store config: %w", err)
}
// metric store 始终启用;未配置时默认 SQLite(./data/metrics.db)。
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s, err := openStore(ctx, cfg)
if err != nil {
return err
}
storeMu.Lock()
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
setLowResourceMode(cfg.LowResourceMode)
clearStoreClosing()
logger.Infof("metricstore", "Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
return nil
}
// RecoverStore opens, persists, and activates a replacement store selected
// from the restricted recovery page. It records that target as the current
// manual-migration source, but does not copy data from the unavailable store.
func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
if cfg == nil {
return fmt.Errorf("metric store recovery config is nil")
}
storeInitMu.Lock()
defer storeInitMu.Unlock()
if err := storeOperations.Acquire(ctx); err != nil {
return fmt.Errorf("wait for metric store operations before recovery: %w", err)
}
defer storeOperations.Release()
if isStoreClosing() {
return ErrStoreBusy
}
recovered := *cfg
recovered.DSN = strings.TrimSpace(recovered.DSN)
recovered.Driver = string(ResolveDriverFromConfig(recovered.Driver, recovered.DSN))
s, err := openStore(ctx, &recovered)
if err != nil {
return err
}
target := targetFingerprint(&recovered)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: recovered.Driver,
MetricDBDSNKey: recovered.DSN,
MigrationTargetKey: target,
}); err != nil {
_ = s.Close()
return fmt.Errorf("save recovered metric store config: %w", err)
}
storeMu.Lock()
old := store
store = s
storeFingerprint = target
storeMu.Unlock()
setLowResourceMode(recovered.LowResourceMode)
clearStoreClosing()
if old != nil {
if closeErr := old.Close(); closeErr != nil {
logger.Errorf("metricstore", "Failed to close previous metric store during recovery: %v", closeErr)
}
// metric store 始终启用;未配置时默认 SQLite(./data/metrics.db)。
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s, err := openStore(ctx, cfg)
if err != nil {
initErr = err
return
}
storeMu.Lock()
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
clearStoreClosing()
log.Printf("Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
})
return initErr
}
logger.Infof("metricstore", "Metric store recovered successfully (driver=%s)", recovered.Driver)
return nil
}
// Reload 根据最新配置热重载 metric store,无需重启进程。
@@ -346,8 +417,7 @@ func InitializeStore() error {
// 成功后再替换运行中的 store,最后关闭旧实例。任何失败都会保留旧 store 不变。
//
// 注意:Reload 只切换运行中的连接,不会把旧目标(如 SQLite)中的历史数据
// 搬运到新目标(如 MySQL/PostgreSQL)。跨库数据迁移由启动迁移
// (RunStartupMigration)在下次启动时按目标指纹自动完成。
// 搬运到新目标(如 MySQL/PostgreSQL)。跨库数据迁移必须由管理员手动启动。
func Reload(ctx context.Context) error {
if err := storeOperations.Acquire(ctx); err != nil {
return fmt.Errorf("wait for metric store operations before reload: %w", err)
@@ -373,14 +443,15 @@ func Reload(ctx context.Context) error {
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
setLowResourceMode(cfg.LowResourceMode)
if old != nil {
if cerr := old.Close(); cerr != nil {
log.Printf("Failed to close previous metric store on reload: %v", cerr)
logger.Errorf("metricstore", "Failed to close previous metric store on reload: %v", cerr)
}
}
log.Printf("Metric store reloaded successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
logger.Infof("metricstore", "Metric store reloaded successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
return nil
}
@@ -428,9 +499,13 @@ func summarizeRetentionDefinitions(defs []metric.Definition) RetentionSummary {
}
func Compact(ctx context.Context, now time.Time) (int, error) {
if !storeOperations.TryAcquire() {
if !compactOperations.TryAcquire() {
return 0, ErrCompactInProgress
}
defer compactOperations.Release()
if err := storeOperations.Acquire(ctx); err != nil {
return 0, fmt.Errorf("wait for metric store operations before compaction: %w", err)
}
defer storeOperations.Release()
storeMu.RLock()
@@ -454,20 +529,29 @@ func Compact(ctx context.Context, now time.Time) (int, error) {
total := 0
start := compactAt
failedAt := -1
var compactErrors []error
for i := 0; i < len(defs); i++ {
idx := (start + i) % len(defs)
n, err := activeStore.CompactMetric(ctx, defs[idx].Name, now)
if err != nil {
compactAt = idx
return total, fmt.Errorf("compact metric %q: %w", defs[idx].Name, err)
if failedAt < 0 {
failedAt = idx
}
compactErrors = append(compactErrors, fmt.Errorf("compact metric %q: %w", defs[idx].Name, err))
continue
}
total += n
compactAt = (idx + 1) % len(defs)
}
if _, err := activeStore.CleanupExpired(ctx, now); err != nil {
return total, fmt.Errorf("clean up expired raw metrics: %w", err)
compactErrors = append(compactErrors, fmt.Errorf("clean up expired raw metrics: %w", err))
}
return total, nil
if failedAt >= 0 {
compactAt = failedAt
} else {
compactAt = start
}
return total, errors.Join(compactErrors...)
}
// CloseStoreContext stops the asynchronous store migration before taking the
@@ -503,6 +587,12 @@ func createMetricDefinitions(ctx context.Context, s *metric.Store) error {
return createMetricDefinitionsWithDefaultRetention(ctx, s, defaultBuiltinMetricRetentionDays)
}
// EnsureBuiltinMetricDefinitions registers definitions for the server's
// built-in report and ping writers before a standalone Store receives points.
func EnsureBuiltinMetricDefinitions(ctx context.Context, s *metric.Store) error {
return createMetricDefinitions(ctx, s)
}
func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.Store, defaultRetentionDays int) error {
if defaultRetentionDays < defaultBuiltinMetricRetentionDays {
defaultRetentionDays = defaultBuiltinMetricRetentionDays
@@ -515,13 +605,9 @@ func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.
{Name: MetricGPUMemTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory total", RetentionDays: defaultRetentionDays},
{Name: MetricGPUTemp, Type: metric.TypeGauge, Unit: "°C", Description: "GPU temperature", RetentionDays: defaultRetentionDays},
{Name: MetricRAM, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM used", RetentionDays: defaultRetentionDays},
{Name: MetricRAMTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM total", RetentionDays: defaultRetentionDays},
{Name: MetricSwap, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap used", RetentionDays: defaultRetentionDays},
{Name: MetricSwapTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap total", RetentionDays: defaultRetentionDays},
{Name: MetricLoad, Type: metric.TypeGauge, Unit: "", Description: "System load average", RetentionDays: defaultRetentionDays},
{Name: MetricTemp, Type: metric.TypeGauge, Unit: "°C", Description: "System temperature", RetentionDays: defaultRetentionDays},
{Name: MetricDisk, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk used", RetentionDays: defaultRetentionDays},
{Name: MetricDiskTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk total", RetentionDays: defaultRetentionDays},
{Name: MetricNetIn, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network in rate", RetentionDays: defaultRetentionDays},
{Name: MetricNetOut, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network out rate", RetentionDays: defaultRetentionDays},
{Name: MetricNetTotalUp, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total upload", RetentionDays: defaultRetentionDays},
@@ -553,6 +639,11 @@ func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.
return fmt.Errorf("failed to create metric %s: %w", def.Name, err)
}
}
for _, name := range obsoleteBuiltinMetricNames {
if err := s.DeleteMetric(ctx, name); err != nil {
return fmt.Errorf("failed to remove obsolete metric %s: %w", name, err)
}
}
return nil
}
@@ -564,33 +655,48 @@ func WritePingRecord(ctx context.Context, rec models.PingRecord) error {
return fmt.Errorf("metric store not enabled")
}
ts := rec.Time
entityID := rec.Client
tags := map[string]string{
"task_id": fmt.Sprintf("%d", rec.TaskId),
reportBatcherMu.Lock()
worker := reportBatcher
reportBatcherMu.Unlock()
if worker != nil {
return worker.enqueuePing(ctx, rec)
}
return writePingRecords(ctx, []models.PingRecord{rec})
}
func writePingRecords(ctx context.Context, records []models.PingRecord) error {
s := GetStore()
if s == nil {
return fmt.Errorf("metric store not enabled")
}
if len(records) == 0 {
return nil
}
loss := 0.0
if rec.Value < 0 {
loss = 1
points := make([]metric.Point, 0, len(records)*2)
for _, rec := range records {
tags := map[string]string{"task_id": fmt.Sprintf("%d", rec.TaskId)}
loss := 0.0
if rec.Value < 0 {
loss = 1
}
points = append(points,
metric.Point{
MetricName: MetricPingLatency,
EntityID: rec.Client,
Timestamp: rec.Time,
Value: float64(rec.Value),
Tags: tags,
},
metric.Point{
MetricName: MetricPingLoss,
EntityID: rec.Client,
Timestamp: rec.Time,
Value: loss,
Tags: tags,
},
)
}
points := []metric.Point{
{
MetricName: MetricPingLatency,
EntityID: entityID,
Timestamp: ts,
Value: float64(rec.Value),
Tags: tags,
},
{
MetricName: MetricPingLoss,
EntityID: entityID,
Timestamp: ts,
Value: loss,
Tags: tags,
},
}
return s.WriteBatch(ctx, points)
}
@@ -739,20 +845,12 @@ func applyRecordMetricValue(rec *models.Record, metricName string, value float64
rec.Gpu = float32(value)
case MetricRAM:
rec.Ram = int64(value)
case MetricRAMTotal:
rec.RamTotal = int64(value)
case MetricSwap:
rec.Swap = int64(value)
case MetricSwapTotal:
rec.SwapTotal = int64(value)
case MetricLoad:
rec.Load = float32(value)
case MetricTemp:
rec.Temp = float32(value)
case MetricDisk:
rec.Disk = int64(value)
case MetricDiskTotal:
rec.DiskTotal = int64(value)
case MetricNetIn:
rec.NetIn = int64(value)
case MetricNetOut:
@@ -944,7 +1042,7 @@ func DeleteAllRecords(ctx context.Context) error {
for _, metricName := range recordMetricNames {
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
log.Printf("Failed to delete metric %s: %v", metricName, err)
logger.Errorf("metricstore", "Failed to delete metric %s: %v", metricName, err)
}
}
clearReportTrafficStates()
@@ -997,3 +1095,28 @@ func DeleteEntity(ctx context.Context, entityID string) error {
deleteReportTrafficState(entityID)
return nil
}
// DeleteEntityAsync clears one agent's metric history without delaying the
// client deletion response.
func DeleteEntityAsync(entityID string) {
go func() {
if err := DeleteEntity(context.Background(), entityID); err != nil {
logger.Errorf("metricstore", "Failed to delete metric records for entity %s: %v", entityID, err)
}
}()
}
// DeleteMetricDataAsync clears disabled metric history without delaying an
// admin retention-policy update response.
func DeleteMetricDataAsync(metricName string) {
go func() {
s := GetStore()
if s == nil {
logger.Errorf("metricstore", "Failed to delete disabled metric %s: metric store not enabled", metricName)
return
}
if _, err := s.DeleteMetricDataIfDisabled(context.Background(), metricName); err != nil {
logger.Errorf("metricstore", "Failed to delete disabled metric %s: %v", metricName, err)
}
}()
}
+137 -15
View File
@@ -2,6 +2,10 @@ package metricstore
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
@@ -36,10 +40,9 @@ func TestDefaultRollupPolicy(t *testing.T) {
func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: true,
TablePrefix: "metric_",
Driver: "sqlite",
DSN: ":memory:",
TablePrefix: "metric_",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
@@ -54,9 +57,8 @@ func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: true,
Driver: "sqlite",
DSN: ":memory:",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
@@ -68,17 +70,42 @@ func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
}
}
func TestBuildMetricConfigCanDisableDownsampling(t *testing.T) {
func TestBuildMetricConfigAlwaysEnablesDownsampling(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: false,
Driver: "sqlite",
DSN: ":memory:",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.RollupPolicy.Enabled() {
t.Fatal("expected rollup policy to be disabled")
if !cfg.RollupPolicy.Enabled() {
t.Fatal("expected rollup policy to be enabled")
}
}
func TestBuildMetricConfigUsesSmallSQLiteProfileInLowResourceMode(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: "./data/metrics.db",
LowResourceMode: true,
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.SQLite.CacheSizeKB != 8*1024 {
t.Fatalf("cache size = %dKiB, want 8192KiB", cfg.SQLite.CacheSizeKB)
}
if cfg.SQLite.MMapSizeBytes != 0 {
t.Fatalf("mmap size = %d, want 0", cfg.SQLite.MMapSizeBytes)
}
if cfg.SQLite.TempStoreMemory {
t.Fatal("temp store should use FILE in low resource mode")
}
if cfg.SQLite.ReadPoolSize != 0 {
t.Fatalf("read pool size = %d, want 0", cfg.SQLite.ReadPoolSize)
}
if cfg.SQLite.PerformanceProfile != metric.SQLiteProfileBalanced {
t.Fatalf("SQLite profile = %q, want balanced", cfg.SQLite.PerformanceProfile)
}
}
@@ -152,8 +179,8 @@ func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *te
if err != nil {
t.Fatalf("list definitions: %v", err)
}
if len(defs) != 25 {
t.Fatalf("definition count = %d, want 25", len(defs))
if len(defs) != 21 {
t.Fatalf("definition count = %d, want 21", len(defs))
}
for _, def := range defs {
if def.RetentionDays != defaultBuiltinMetricRetentionDays {
@@ -194,6 +221,34 @@ func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *te
}
}
func TestCreateMetricDefinitionsRemovesObsoleteMetrics(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, metric.Definition{Name: "memory.total", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create obsolete definition: %v", err)
}
if err := s.Write(ctx, metric.Point{MetricName: "memory.total", EntityID: "node-a", Timestamp: time.Now().UTC(), Value: 1024}); err != nil {
t.Fatalf("write obsolete point: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("refresh built-in definitions: %v", err)
}
if _, err := s.GetMetric(ctx, "memory.total"); !errors.Is(err, metric.ErrNotFound) {
t.Fatalf("obsolete definition error = %v, want ErrNotFound", err)
}
points, err := s.Query(ctx, metric.Query{MetricName: "memory.total", EntityID: "node-a", Start: time.Now().UTC().Add(-time.Hour), End: time.Now().UTC().Add(time.Hour)})
if err != nil {
t.Fatalf("query obsolete points: %v", err)
}
if len(points) != 0 {
t.Fatalf("obsolete points remain: %#v", points)
}
}
func TestCreateMetricDefinitionsUsesLegacySpanOnlyForNewDefinitions(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
@@ -351,6 +406,73 @@ func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
}
}
func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
ctx := context.Background()
dsn := filepath.Join(t.TempDir(), "compact.db")
s, err := metric.Open(ctx, metric.SQLite(dsn,
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy()),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
for _, name := range []string{"a.invalid", "b.healthy"} {
if err := s.CreateMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric %s: %v", name, err)
}
}
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
old := now.Add(-time.Hour)
if err := s.Write(ctx, metric.Point{MetricName: "b.healthy", EntityID: "node", Timestamp: old, Value: 2}); err != nil {
t.Fatalf("write healthy point: %v", err)
}
rawDB, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open raw sqlite connection: %v", err)
}
_, err = rawDB.ExecContext(ctx, `INSERT INTO metric_points
(metric_name, entity_id, tags_hash, ts_nano, value, tags, labels, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"a.invalid", "node", "invalid", old.UnixNano(), 1, "not-json", "{}", now.UnixNano(),
)
_ = rawDB.Close()
if err != nil {
t.Fatalf("insert malformed point: %v", err)
}
storeMu.Lock()
previousStore := store
previousCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
t.Cleanup(func() {
storeMu.Lock()
store = previousStore
compactAt = previousCompactAt
storeMu.Unlock()
})
if _, err := Compact(ctx, now); err == nil {
t.Fatal("expected malformed metric to fail compaction")
}
points, err := s.Query(ctx, metric.Query{
MetricName: "b.healthy",
EntityID: "node",
Start: old.Add(-time.Minute),
End: now,
})
if err != nil {
t.Fatalf("query healthy raw points: %v", err)
}
if len(points) != 0 {
t.Fatalf("healthy metric was blocked by another metric failure: %s", fmt.Sprint(points))
}
}
func TestCompactKeepsRotatingCursorAfterFullCycle(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:",
-72
View File
@@ -1,72 +0,0 @@
package metricstore
import (
"fmt"
"log"
"strings"
"github.com/komari-monitor/komari/pkg/config"
)
// migration.go
//
// 启动阶段的 metrics 数据迁移。这里只处理 metrics 存储后端切换时的数据搬运
// (例如默认 SQLite ./data/metrics.db 切换到 MySQL/PostgreSQL)。旧 komari.db
// 监控表导入属于一次性迁移,见 pkg/migrations.RunMetricStoreMigrations。
// targetFingerprint 返回当前 metrics 目标库的指纹(driver + 归一化 DSN),
// 用于判断 metrics 存储后端是否发生变化(例如从 SQLite 切换到 MySQL/PostgreSQL)。
func targetFingerprint(cfg *MetricStoreConfig) string {
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
dsn := strings.TrimSpace(cfg.DSN)
return fmt.Sprintf("%s|%s", driver, dsn)
}
// RunStartupMigration 在服务启动时检查 metrics 存储后端是否发生变化,
// 若变化则把上一个 metrics 目标库的全部数据搬运到当前目标。
//
// 判定依据 MigrationTargetKey(记录“数据当前完整所在的 metrics 目标指纹”):
// - saved == current:数据已在当前目标,无需处理。
// - saved == "":无历史目标记录。老快照的 metrics 数据固定落在默认 SQLite
// (./data/metrics.db);若当前目标不是默认 SQLite(例如已切到 MySQL/PostgreSQL),
// 把默认 SQLite 中可能存在的历史数据搬运过来,否则仅登记指纹。
// - saved != current:后端已切换,把上一个目标(saved)的数据搬运到当前目标。
//
// 搬运以 upsert 写入,幂等,中断后重启可安全重跑。任一失败都返回错误,
// 调用方应让启动失败并打印明确错误。
func RunStartupMigration() error {
s := GetStore()
if s == nil {
return fmt.Errorf("metric store not initialized")
}
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return fmt.Errorf("failed to load metric store config: %w", err)
}
current := targetFingerprint(cfg)
saved, _ := config.GetAs[string](MigrationTargetKey, "")
// 数据已完整位于当前目标:无需搬运。
if saved == current {
return nil
}
// 上一个目标:优先使用已保存指纹;无记录时按老快照默认 SQLite 推断。
prev := saved
if prev == "" {
prev = defaultSQLiteFingerprint()
}
if prev != current {
if err := migrateFromPreviousStore(prev, cfg, s); err != nil {
return err
}
}
if err := config.Set(MigrationTargetKey, current); err != nil {
log.Printf("Failed to persist migration target fingerprint: %v", err)
}
return nil
}
+11 -12
View File
@@ -3,7 +3,7 @@ package metricstore
import (
"context"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"time"
@@ -28,12 +28,11 @@ func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*Metric
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
}
return &MetricStoreConfig{
Driver: driver,
DSN: dsn,
DownsamplingEnabled: base.DownsamplingEnabled,
TablePrefix: base.TablePrefix,
MaxOpenConns: base.MaxOpenConns,
MaxIdleConns: base.MaxIdleConns,
Driver: driver,
DSN: dsn,
TablePrefix: base.TablePrefix,
MaxOpenConns: base.MaxOpenConns,
MaxIdleConns: base.MaxIdleConns,
}, nil
}
@@ -74,12 +73,12 @@ func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, ds
}
defer src.Close()
log.Printf("Migrating metrics from previous store %s to current target...", prevFingerprint)
logger.Infof("metricstore", "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)
logger.Infof("metricstore", "Store-to-store metrics migration completed (%d points)", total)
return nil
}
@@ -103,8 +102,8 @@ func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, e
}
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
// 观察者 observe(为 nil 时行为与旧版本完全一致)。启动迁移走 nil,WebUI/API 触发
// 的迁移传入回调以实时更新进度。
// 观察者 observe(为 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")
@@ -169,7 +168,7 @@ func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe s
}
}
if migrated > 0 {
log.Printf("[store-migration] metric %q: migrated %d points", def.Name, migrated)
logger.Infof("metricstore", "[store-migration] metric %q: migrated %d points", def.Name, migrated)
}
}
+83
View File
@@ -0,0 +1,83 @@
package metricstore
import (
"context"
"path/filepath"
"testing"
"github.com/komari-monitor/komari/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func prepareRecoveryTest(t *testing.T) {
t.Helper()
configDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open config db: %v", err)
}
config.SetDb(configDB)
storeInitMu.Lock()
storeMigMu.Lock()
previousClosing := storeClosing
storeClosing = false
storeMigMu.Unlock()
storeMu.Lock()
previousStore := store
previousFingerprint := storeFingerprint
store = nil
storeFingerprint = ""
storeMu.Unlock()
storeInitMu.Unlock()
t.Cleanup(func() {
_ = CloseStoreContext(context.Background())
storeMigMu.Lock()
storeClosing = previousClosing
storeMigMu.Unlock()
storeMu.Lock()
store = previousStore
storeFingerprint = previousFingerprint
storeMu.Unlock()
})
}
func TestRecoverStoreFailureKeepsCurrentConfig(t *testing.T) {
prepareRecoveryTest(t)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: "sqlite",
MetricDBDSNKey: "old.db",
}); err != nil {
t.Fatalf("save original config: %v", err)
}
missing := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "missing.db")) + "?mode=ro"
err := RecoverStore(context.Background(), &MetricStoreConfig{Driver: "sqlite", DSN: missing})
if err == nil {
t.Fatal("recovery unexpectedly opened a missing read-only database")
}
if got, _ := config.GetAs[string](MetricDBDSNKey, ""); got != "old.db" {
t.Fatalf("DSN changed after failed recovery: %q", got)
}
if GetStore() != nil {
t.Fatal("failed recovery installed a store")
}
}
func TestRecoverStoreRecordsManualMigrationSource(t *testing.T) {
prepareRecoveryTest(t)
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "recovered.db"))
t.Cleanup(func() { _ = CloseStoreContext(context.Background()) })
cfg := &MetricStoreConfig{Driver: "sqlite", DSN: dsn}
if err := RecoverStore(context.Background(), cfg); err != nil {
t.Fatalf("recover store: %v", err)
}
if GetStore() == nil {
t.Fatal("successful recovery did not install a store")
}
wantTarget := targetFingerprint(cfg)
if got, _ := config.GetAs[string](MigrationTargetKey, ""); got != wantTarget {
t.Fatalf("migration target = %q, want %q", got, wantTarget)
}
}
+22
View File
@@ -0,0 +1,22 @@
package metricstore
import (
"regexp"
"strings"
)
var (
metricCredentialPattern = regexp.MustCompile(`(?i)\b(password|passwd)\s*=\s*('[^']*'|"[^"]*"|[^\s,;)&]+)`)
metricURLCredentialPattern = regexp.MustCompile(`(?i)([a-z0-9_.-]+):([^@\s]+)@`)
)
// RedactConnectionError removes the supplied DSN and common password fragments
// before a connection error is shown to an administrator or written to a log.
func RedactConnectionError(message, dsn string) string {
message = strings.TrimSpace(message)
if dsn != "" {
message = strings.ReplaceAll(message, dsn, "[redacted]")
}
message = metricCredentialPattern.ReplaceAllString(message, "$1=[redacted]")
return metricURLCredentialPattern.ReplaceAllString(message, "$1:[redacted]@")
}
+260 -79
View File
@@ -4,9 +4,12 @@ import (
"context"
"errors"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"runtime"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/komari-monitor/komari/database/models"
@@ -32,20 +35,25 @@ type reportTrafficValues struct {
var reportTrafficStates sync.Map
const (
reportBatchInterval = 3 * time.Second
reportBatchQueueSize = 4096
reportBatchMaxReports = 256 * 21
reportBatchWriteTimeout = 10 * time.Second
reportBatchInterval = 3 * time.Second
lowResourceReportWindow = 30 * time.Second
reportBatchQueueSize = 4096
lowResourceBatchMaxReports = 128
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
)
var (
reportBatcherMu sync.Mutex
reportBatcher *reportBatchWorker
lowResourceMode atomic.Bool
droppedReports atomic.Uint64
)
var (
ErrReportBatchQueueFull = errors.New("metric report batch queue is full")
ErrReportBatchStopped = errors.New("metric report batcher is stopped")
ErrPingBatchQueueFull = errors.New("ping batch queue is full")
)
type reportBatchRequest struct {
@@ -55,15 +63,24 @@ type reportBatchRequest struct {
}
type reportBatchWorker struct {
mu sync.Mutex
queue chan v1.Report
requests chan reportBatchRequest
done chan struct{}
stopping bool
mu sync.Mutex
queue chan v1.Report
pingQueue chan models.PingRecord
requests chan reportBatchRequest
done chan struct{}
stopping bool
}
// StartReportBatcher starts the shared report writer. Reports are collected for
// a short window so many agents can be persisted in one database transaction.
func setLowResourceMode(enabled bool) {
lowResourceMode.Store(enabled)
}
func LowResourceModeEnabled() bool {
return lowResourceMode.Load()
}
// StartReportBatcher starts the shared report writer. Normal mode uses a short
// window; low-resource mode coalesces each node's 30-second window before write.
func StartReportBatcher() {
reportBatcherMu.Lock()
defer reportBatcherMu.Unlock()
@@ -71,9 +88,10 @@ func StartReportBatcher() {
return
}
worker := &reportBatchWorker{
queue: make(chan v1.Report, reportBatchQueueSize),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
queue: make(chan v1.Report, reportBatchQueueSize),
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
reportBatcher = worker
go worker.run()
@@ -113,8 +131,8 @@ func StopReportBatcher(ctx context.Context) error {
}
// FlushReportBatch synchronously flushes the current queue. It is useful for
// controlled handoff points and deterministic tests; normal operation relies
// on the three-second ticker.
// controlled handoff points and deterministic tests; normal operation uses the
// worker ticker and the active mode's flush interval.
func FlushReportBatch(ctx context.Context) error {
reportBatcherMu.Lock()
worker := reportBatcher
@@ -164,6 +182,10 @@ func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
reportBatcherMu.Unlock()
if worker != nil {
if err := worker.enqueue(ctx, report); err != nil {
if LowResourceModeEnabled() && errors.Is(err, ErrReportBatchQueueFull) {
droppedReports.Add(1)
return report, nil
}
return v1.Report{}, err
}
return report, nil
@@ -198,31 +220,68 @@ func (w *reportBatchWorker) run() {
defer ticker.Stop()
var pending []v1.Report
var pendingPings []models.PingRecord
lastFlush := time.Now()
for {
select {
case report := <-w.queue:
pending = append(pending, report)
case request := <-w.requests:
pending = append(pending, drainReportQueue(w.queue, reportBatchMaxReports-len(pending))...)
err := writePendingReports(request.ctx, &pending)
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
err := errors.Join(
writePendingReports(request.ctx, &pending, LowResourceModeEnabled()),
writePendingPingRecords(request.ctx, &pendingPings),
)
if request.stop {
if err != nil {
log.Printf("failed to flush metric report batch during shutdown: %v", err)
logger.Errorf("metricstore", "failed to flush metric report batch during shutdown: %v", err)
}
close(w.done)
request.done <- err
return
}
if err == nil {
lastFlush = time.Now()
}
request.done <- err
case <-ticker.C:
pending = append(pending, drainReportQueue(w.queue, reportBatchMaxReports-len(pending))...)
if err := writePendingReports(context.Background(), &pending); err != nil {
log.Printf("failed to flush metric report batch: %v", err)
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
if err := writePendingPingRecords(context.Background(), &pendingPings); err != nil {
logger.Errorf("metricstore", "failed to flush ping batch: %v", err)
}
lowResource := LowResourceModeEnabled()
if lowResource && time.Since(lastFlush) < lowResourceReportWindow {
continue
}
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
if err := writePendingReports(context.Background(), &pending, lowResource); err != nil {
logger.Errorf("metricstore", "failed to flush metric report batch: %v", err)
} else {
lastFlush = time.Now()
if dropped := droppedReports.Swap(0); dropped > 0 {
logger.Warnf("metricstore", "low resource metric batching dropped %d reports after the queue filled", dropped)
}
}
}
}
}
func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingRecord) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopping {
return ErrReportBatchStopped
}
if err := ctx.Err(); err != nil {
return err
}
select {
case w.pingQueue <- record:
return nil
default:
return ErrPingBatchQueueFull
}
}
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
if limit <= 0 {
return nil
@@ -239,23 +298,170 @@ func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
return reports
}
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingRecord {
if limit <= 0 {
return nil
}
records := make([]models.PingRecord, 0, limit)
for len(records) < limit {
select {
case record := <-queue:
records = append(records, record)
default:
return records
}
}
return records
}
func writePendingReports(ctx context.Context, pending *[]v1.Report, lowResource bool) error {
if len(*pending) == 0 {
return nil
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
defer cancel()
if err := func() error {
reports := *pending
_, err := writeReportBatch(writeCtx, reports)
return err
}(); err != nil {
return err
if lowResource {
*pending = coalesceReportsP95(*pending)
}
batchSize := len(*pending)
if lowResource && batchSize > lowResourceBatchMaxReports {
batchSize = lowResourceBatchMaxReports
}
for len(*pending) > 0 {
if batchSize > len(*pending) {
batchSize = len(*pending)
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
_, err := writeReportBatch(writeCtx, (*pending)[:batchSize])
cancel()
if err != nil {
return err
}
*pending = (*pending)[batchSize:]
if lowResource {
runtime.Gosched()
}
}
*pending = (*pending)[:0]
return nil
}
func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord) error {
for len(*pending) > 0 {
batchSize := len(*pending)
if batchSize > pingBatchMaxRecords {
batchSize = pingBatchMaxRecords
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
err := writePingRecords(writeCtx, (*pending)[:batchSize])
cancel()
if err != nil {
return err
}
*pending = (*pending)[batchSize:]
}
return nil
}
type reportNumber interface {
~int | ~int64 | ~float64
}
func percentile95[T reportNumber](values []T) T {
if len(values) == 0 {
var zero T
return zero
}
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
index := (95*len(values) + 99) / 100
return values[index-1]
}
func reportP95[T reportNumber](reports []v1.Report, value func(v1.Report) T) T {
values := make([]T, len(reports))
for i, report := range reports {
values[i] = value(report)
}
return percentile95(values)
}
func coalesceReportsP95(reports []v1.Report) []v1.Report {
grouped := make(map[string][]v1.Report)
order := make([]string, 0)
for _, report := range reports {
if _, exists := grouped[report.UUID]; !exists {
order = append(order, report.UUID)
}
grouped[report.UUID] = append(grouped[report.UUID], report)
}
coalesced := make([]v1.Report, 0, len(order))
for _, uuid := range order {
coalesced = append(coalesced, coalesceNodeReportsP95(grouped[uuid]))
}
return coalesced
}
func coalesceNodeReportsP95(reports []v1.Report) v1.Report {
latestIndex := 0
for i := 1; i < len(reports); i++ {
if reports[i].UpdatedAt.After(reports[latestIndex].UpdatedAt) {
latestIndex = i
}
}
result := reports[latestIndex]
result.CPU.Usage = reportP95(reports, func(r v1.Report) float64 { return r.CPU.Usage })
result.Ram.Used = reportP95(reports, func(r v1.Report) int64 { return r.Ram.Used })
result.Swap.Used = reportP95(reports, func(r v1.Report) int64 { return r.Swap.Used })
result.Load.Load1 = reportP95(reports, func(r v1.Report) float64 { return r.Load.Load1 })
result.Disk.Used = reportP95(reports, func(r v1.Report) int64 { return r.Disk.Used })
result.Network.Up = reportP95(reports, func(r v1.Report) int64 { return r.Network.Up })
result.Network.Down = reportP95(reports, func(r v1.Report) int64 { return r.Network.Down })
result.Process = reportP95(reports, func(r v1.Report) int { return r.Process })
result.Connections.TCP = reportP95(reports, func(r v1.Report) int { return r.Connections.TCP })
result.Connections.UDP = reportP95(reports, func(r v1.Report) int { return r.Connections.UDP })
latestGPU := -1
for i := range reports {
if reports[i].GPU != nil && (latestGPU < 0 || reports[i].UpdatedAt.After(reports[latestGPU].UpdatedAt)) {
latestGPU = i
}
}
if latestGPU < 0 {
result.GPU = nil
return result
}
gpu := *reports[latestGPU].GPU
gpu.DetailedInfo = append([]v1.GPUDeviceInfo(nil), gpu.DetailedInfo...)
gpuReports := make([]v1.Report, 0, len(reports))
for _, report := range reports {
if report.GPU != nil {
gpuReports = append(gpuReports, report)
}
}
gpu.AverageUsage = reportP95(gpuReports, func(r v1.Report) float64 { return r.GPU.AverageUsage })
for deviceIndex := range gpu.DetailedInfo {
deviceReports := make([]v1.GPUDeviceInfo, 0, len(gpuReports))
for _, report := range gpuReports {
if deviceIndex < len(report.GPU.DetailedInfo) {
deviceReports = append(deviceReports, report.GPU.DetailedInfo[deviceIndex])
}
}
if len(deviceReports) == 0 {
continue
}
memoryUsed := make([]int64, len(deviceReports))
utilization := make([]float64, len(deviceReports))
temperature := make([]int, len(deviceReports))
for i, device := range deviceReports {
memoryUsed[i] = device.MemoryUsed
utilization[i] = device.Utilization
temperature[i] = device.Temperature
}
gpu.DetailedInfo[deviceIndex].MemoryUsed = percentile95(memoryUsed)
gpu.DetailedInfo[deviceIndex].Utilization = percentile95(utilization)
gpu.DetailedInfo[deviceIndex].Temperature = percentile95(temperature)
}
result.GPU = &gpu
return result
}
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
if len(reports) == 0 {
return nil, nil
@@ -284,14 +490,22 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
state.mu.Unlock()
}
if !values.initialized {
var err error
values.totalUp, values.hasUp, err = latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("load previous upload counter: %w", err)
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
if err == nil {
values.totalUp = totalUp
values.hasUp = hasUp
} else if ctx.Err() == nil {
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
}
values.totalDown, values.hasDown, err = latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("load previous download counter: %w", err)
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
if err == nil {
values.totalDown = totalDown
values.hasDown = hasDown
} else if ctx.Err() == nil {
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
values.initialized = true
}
@@ -331,21 +545,12 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
entityID := report.UUID
ts := report.UpdatedAt
gpuUsage := 0.0
if report.GPU != nil {
gpuUsage = report.GPU.AverageUsage
}
points := []metric.Point{
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: gpuUsage},
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
{MetricName: MetricRAMTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Total)},
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
{MetricName: MetricSwapTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Total)},
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
{MetricName: MetricTemp, EntityID: entityID, Timestamp: ts, Value: 0},
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
{MetricName: MetricDiskTotal, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Total)},
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
@@ -359,6 +564,7 @@ func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric
if report.GPU == nil {
return points
}
points = append(points, metric.Point{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: report.GPU.AverageUsage})
for deviceIndex, gpu := range report.GPU.DetailedInfo {
tags := map[string]string{
"device_index": strconv.Itoa(deviceIndex),
@@ -375,39 +581,14 @@ func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric
}
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
def, err := s.GetMetric(ctx, metricName)
if errors.Is(err, metric.ErrNotFound) {
return 0, false, nil
}
point, ok, err := s.LatestBefore(ctx, metricName, entityID, before)
if err != nil {
return 0, false, err
}
if def.RetentionDays <= 0 {
if !ok {
return 0, false, nil
}
end := before.UTC().Add(-time.Nanosecond)
start := end.Add(-time.Duration(def.RetentionDays) * 24 * time.Hour)
interval := s.CompatibleSeriesInterval(start, before, 24*time.Hour)
points, err := s.Series(ctx, metric.AggregateQuery{
Query: metric.Query{
MetricName: metricName,
EntityID: entityID,
Start: start,
End: end,
Order: metric.OrderAsc,
},
Aggregation: metric.AggLast,
Interval: interval,
PreserveSeries: true,
}, before)
if err != nil {
return 0, false, err
}
if len(points) == 0 {
return 0, false, nil
}
return int64(points[len(points)-1].Value), true, nil
return int64(point.Value), true, nil
}
// GetLatestTrafficBefore returns the latest retained upload/download counters
+188
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
@@ -118,6 +119,27 @@ func TestWriteReportStoresRawMetricsAndResetAwareTraffic(t *testing.T) {
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
}
func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
timestamp := time.Now().UTC()
if _, err := WriteReport(ctx, v1.Report{
UUID: "node-without-gpu", UpdatedAt: timestamp,
}); err != nil {
t.Fatalf("write report: %v", err)
}
points, err := s.Query(ctx, metric.Query{
MetricName: MetricGPU, EntityID: "node-without-gpu",
Start: timestamp.Add(-time.Second), End: timestamp.Add(time.Second),
})
if err != nil {
t.Fatalf("query GPU metric: %v", err)
}
if len(points) != 0 {
t.Fatalf("GPU metric was written without GPU data: %#v", points)
}
}
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
@@ -169,6 +191,172 @@ func TestReportBatcherFlushesQueuedReports(t *testing.T) {
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
}
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
StartReportBatcher()
t.Cleanup(func() {
if err := StopReportBatcher(ctx); err != nil {
t.Errorf("stop report batcher: %v", err)
}
})
base := time.Now().UTC().Truncate(time.Second)
records := []models.PingRecord{
{Client: "ping-node", TaskId: 7, Time: base, Value: 24},
{Client: "ping-node", TaskId: 7, Time: base.Add(time.Minute), Value: -1},
{Client: "ping-node", TaskId: 8, Time: base, Value: 31},
}
for _, record := range records {
if err := WritePingRecord(ctx, record); err != nil {
t.Fatalf("queue ping record: %v", err)
}
}
for _, name := range []string{MetricPingLatency, MetricPingLoss} {
points, err := s.Query(ctx, metric.Query{
MetricName: name,
EntityID: "ping-node",
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
})
if err != nil {
t.Fatalf("query queued %s points: %v", name, err)
}
if len(points) != 0 {
t.Fatalf("queued %s points were written before flush: %#v", name, points)
}
}
if err := FlushReportBatch(ctx); err != nil {
t.Fatalf("flush ping batch: %v", err)
}
latency, err := s.Query(ctx, metric.Query{
MetricName: MetricPingLatency,
EntityID: "ping-node",
Tags: map[string]string{"task_id": "7"},
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query flushed latency points: %v", err)
}
if len(latency) != 2 || latency[0].Value != 24 || latency[1].Value != -1 {
t.Fatalf("latency points = %#v, want both original samples", latency)
}
loss, err := s.Query(ctx, metric.Query{
MetricName: MetricPingLoss,
EntityID: "ping-node",
Tags: map[string]string{"task_id": "7"},
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query flushed loss points: %v", err)
}
if len(loss) != 2 || loss[0].Value != 0 || loss[1].Value != 1 {
t.Fatalf("loss points = %#v, want success and loss samples", loss)
}
}
func TestCoalesceReportsP95KeepsLatestCounters(t *testing.T) {
base := time.Now().UTC().Truncate(time.Second)
reports := make([]v1.Report, 20)
for i := range reports {
value := i + 1
reports[i] = v1.Report{
UUID: "p95-node",
UpdatedAt: base.Add(time.Duration(i) * time.Second),
CPU: v1.CPUReport{Usage: float64(value)},
Ram: v1.RamReport{Used: int64(value)},
Network: v1.NetworkReport{
Up: int64(value),
TotalUp: int64(value * 100),
TotalDown: int64(value * 200),
},
Process: value,
}
}
got := coalesceReportsP95(reports)
if len(got) != 1 {
t.Fatalf("coalesced report count = %d, want 1", len(got))
}
if got[0].CPU.Usage != 19 || got[0].Ram.Used != 19 || got[0].Network.Up != 19 || got[0].Process != 19 {
t.Fatalf("P95 fields = %#v, want nearest-rank P95 of 19", got[0])
}
if got[0].Network.TotalUp != 2000 || got[0].Network.TotalDown != 4000 {
t.Fatalf("cumulative counters = %#v, want latest report counters", got[0].Network)
}
if !got[0].UpdatedAt.Equal(base.Add(19 * time.Second)) {
t.Fatalf("timestamp = %s, want latest timestamp", got[0].UpdatedAt)
}
}
func TestLowResourceBatchWritesOneReportPerNode(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
base := time.Now().UTC().Truncate(time.Second)
pending := []v1.Report{
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 10}, Network: v1.NetworkReport{TotalUp: 100}},
{UUID: "node-a", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
{UUID: "node-b", UpdatedAt: base, CPU: v1.CPUReport{Usage: 30}, Network: v1.NetworkReport{TotalUp: 200}},
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 40}, Network: v1.NetworkReport{TotalUp: 260}},
}
if err := writePendingReports(ctx, &pending, true); err != nil {
t.Fatalf("write low resource batch: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending reports = %d, want 0", len(pending))
}
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{20})
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{40})
}
func TestLowResourceQueueFullDoesNotBlockRealtimeReport(t *testing.T) {
ctx := context.Background()
useReportTestStore(t, nil)
worker := &reportBatchWorker{
queue: make(chan v1.Report, 1),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
worker.queue <- v1.Report{UUID: "already-queued"}
reportBatcherMu.Lock()
reportBatcher = worker
reportBatcherMu.Unlock()
setLowResourceMode(true)
t.Cleanup(func() {
reportBatcherMu.Lock()
if reportBatcher == worker {
reportBatcher = nil
}
reportBatcherMu.Unlock()
setLowResourceMode(false)
droppedReports.Store(0)
})
report := v1.Report{
UUID: "realtime-node",
UpdatedAt: time.Now().UTC(),
}
saved, err := WriteReport(ctx, report)
if err != nil {
t.Fatalf("low resource queue full should not fail realtime ingest: %v", err)
}
if saved.UUID != report.UUID {
t.Fatalf("saved report UUID = %q, want %q", saved.UUID, report.UUID)
}
if droppedReports.Load() != 1 {
t.Fatalf("dropped reports = %d, want 1", droppedReports.Load())
}
}
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
+9 -12
View File
@@ -3,12 +3,12 @@ package metricstore
import (
"context"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
)
@@ -18,18 +18,15 @@ import (
// ./data/metrics.db)中的全部指标数据搬运到当前正在运行的 metrics 目标库
// (通常是用户新配置并热重载后的 MySQL/PostgreSQL)。
//
// 与启动迁移(RunStartupMigration)的区别:
// - 启动迁移在进程启动时自动、同步执行(komari.db → 当前 metrics 目标,或上一个
// metrics 目标 → 当前目标)。
// - 此处的 store 迁移由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可
// 查询进度 / 取消。数据以 upsert 写入,可安全重复执行。
// 此迁移仅能由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可查询进度 /
// 取消。服务启动和指标库热重载均不会复制历史数据。数据以 upsert 写入,可安全重复执行。
//
// 典型流程:
// 1. 管理员在设置中把 metric_db_dsn 改为 MySQL/PostgreSQL,保存 → 后端连接测试
// 通过后热重载,当前 store 切到远端(此时远端为空)。
// 2. 管理员调用 startMetricMigration(不带 source 参数),系统以上一个 metrics
// 目标(默认 SQLite metrics.db)为源,把历史数据搬运到远端。
// 3. 完成后登记目标指纹,下次启动不再重复搬运。
// 3. 完成后登记目标指纹,供下一次手动迁移默认选择源库。
// StoreMigrationProgress 描述一次 store-to-store 迁移的实时进度。
type StoreMigrationProgress struct {
@@ -191,19 +188,19 @@ func runStoreMigration(ctx context.Context, cancel context.CancelFunc, done chan
if err != nil {
if ctx.Err() != nil {
finishStoreMigration("canceled", nil)
log.Printf("[store-migration] canceled after %d points", total)
logger.Warnf("metricstore", "[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)
logger.Errorf("metricstore", "[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))
logger.Infof("metricstore", "[store-migration] completed via API (%d points) target_driver=%s", total, ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
}
// finishStoreMigration 统一收尾:设置终态状态、结束时间与错误信息。
+2 -3
View File
@@ -149,9 +149,8 @@ func DeleteAllPingRecords() error {
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
if err := db.Find(&pingTasks).Error; err != nil {
pingTasks, err := GetAllPingTasks()
if err != nil {
return err
}
return utils.ReloadPingSchedule(pingTasks)
+26
View File
@@ -40,3 +40,29 @@ func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
}
}
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
tasks := []models.PingTask{
{Name: "third", Weight: 2, Type: "icmp", Target: "third.example", Interval: 60},
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
{Name: "second", Weight: 0, Type: "icmp", Target: "second.example", Interval: 60},
}
if err := db.Create(&tasks).Error; err != nil {
t.Fatalf("create ping tasks: %v", err)
}
ordered, err := GetAllPingTasks()
if err != nil {
t.Fatalf("get ordered ping tasks: %v", err)
}
if len(ordered) != len(tasks) {
t.Fatalf("ordered ping task count = %d, want %d", len(ordered), len(tasks))
}
if ordered[0].Id != tasks[1].Id || ordered[1].Id != tasks[2].Id || ordered[2].Id != tasks[0].Id {
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ package database
import (
"context"
"encoding/json"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"os"
"path/filepath"
"strings"
@@ -12,7 +12,7 @@ import (
"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"
"github.com/komari-monitor/komari/internal/config"
)
func GetPublicInfo() (map[string]interface{}, error) {
@@ -67,7 +67,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
log.Printf("%v", err)
logger.Infof("database", "%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
+2 -9
View File
@@ -55,14 +55,7 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
3. ブラウザで `http://<your_server_ip>:25774` にアクセスし、インストールガイドを完了します。ガイドで管理者アカウント、サイト情報、監視データベースを設定します。
### 3. バイナリファイル展開
@@ -72,7 +65,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
4. インストールガイドに従って管理者アカウント、サイト情報、監視データベースを設定します。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
+2 -9
View File
@@ -55,14 +55,7 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
3. 在瀏覽器中存取 `http://<your_server_ip>:25774` 並完成安裝精靈。精靈會建立管理員帳號,並設定站點中繼資料和監控資料庫。
### 3. 二進位檔案部署
@@ -72,7 +65,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
4. 按照安裝精靈建立管理員帳號,並設定站點中繼資料和監控資料庫。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
+2 -9
View File
@@ -55,14 +55,7 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
3. 在浏览器中访问 `http://<your_server_ip>:25774` 并完成安装向导。向导会创建管理员账号,并设置站点元信息和监控数据库。
### 3. 二进制文件部署
@@ -72,7 +65,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
4. 按照安装向导创建管理员账号,并设置站点元信息和监控数据库。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
golang.org/x/image v0.35.0
golang.org/x/sys v0.33.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
@@ -67,7 +68,6 @@ require (
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -3,11 +3,11 @@ package config
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
logger "github.com/komari-monitor/komari/utils/log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -105,7 +105,7 @@ func GetMany(keys map[string]any) (map[string]any, error) {
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
slog.Warn("marshal default value failed", "key", k, "error", err)
logger.Warn("config", "marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
@@ -122,7 +122,7 @@ func GetMany(keys map[string]any) (map[string]any, error) {
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
logger.Warn("config", "batch insert default config failed", "error", err)
}
}
@@ -199,18 +199,18 @@ func GetManyAs[T any]() (*T, error) {
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
logger.Warn("config", "unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
slog.Warn("parse default value failed", "key", fi.key, "error", err)
logger.Warn("config", "parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
logger.Warn("config", "marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
@@ -227,7 +227,7 @@ func GetManyAs[T any]() (*T, error) {
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
logger.Warn("config", "batch insert default config failed", "error", err)
}
}
@@ -17,6 +17,7 @@ type Settings struct {
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
LowResourceMode bool `json:"low_resource_mode"` // 低资源模式;首次启动按主机性能自动选择
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
// GeoIP 配置
@@ -55,6 +56,7 @@ const (
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
VisitorAuditEnabledKey = "visitor_audit_enabled"
LowResourceModeKey = "low_resource_mode"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
@@ -74,4 +76,5 @@ const (
TrafficLimitPercentageKey = "traffic_limit_percentage"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
ThemeMarketSourcesKey = "theme_market_sources"
)
File renamed without changes.
@@ -2,14 +2,18 @@ package migrations
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"math"
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
"gorm.io/gorm"
)
@@ -17,15 +21,11 @@ import (
const (
legacyMonitoringMigrationDoneKey = "migration_legacy_monitoring_to_metric_store_done"
legacyMonitoringBatchSize = 500
legacyMonitoringInterval = time.Hour
)
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
@@ -34,8 +34,8 @@ type LegacyMonitoringStats struct {
// LegacyMonitoringSummary describes the legacy source data shown by the
// pre-start upgrade wizard. Row counts deliberately stay separate from the
// estimated metric point count because one legacy row expands into multiple
// metric points.
// estimated metric point count because many source rows collapse into one
// hourly P95 point per metric, entity, and tag set.
type LegacyMonitoringSummary struct {
LoadRows int64 `json:"load_rows"`
GPURows int64 `json:"gpu_rows"`
@@ -69,45 +69,23 @@ type legacyMonitoringBounds struct {
type legacyBatchProgress func(table string, rows, points int64)
type legacyRecordRow struct {
RowID int64 `gorm:"column:row_id"`
models.Record
type legacyP95Group struct {
metricName string
entityID string
tags map[string]string
values []float64
}
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
type legacyHourlyP95Aggregator struct {
store *metric.Store
partitionKey string
bucket time.Time
groups map[string]*legacyP95Group
}
// LegacyMonitoringMigrationRequired reports whether startup must enter the
// restricted 1.2.7 upgrade flow. Empty legacy tables are handled by the normal
// one-shot migration so fresh installations never see the wizard.
// restricted 1.2.7 upgrade flow. The migration itself runs only after an
// administrator explicitly starts it from that guide.
func LegacyMonitoringMigrationRequired(db *gorm.DB) (bool, LegacyMonitoringSummary, error) {
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
if err != nil {
@@ -171,7 +149,6 @@ func InspectLegacyMonitoring(db *gorm.DB) (LegacyMonitoringSummary, error) {
}
summary.MonitoringRows = summary.LoadRows + summary.GPURows + summary.LatencyRows
summary.EstimatedPoints = summary.LoadRows*19 + summary.GPURows*4 + summary.LatencyRows*2
if !oldest.IsZero() {
oldestCopy := oldest
summary.OldestAt = &oldestCopy
@@ -181,6 +158,11 @@ func InspectLegacyMonitoring(db *gorm.DB) (LegacyMonitoringSummary, error) {
summary.NewestAt = &newestCopy
}
if !oldest.IsZero() && !newest.IsZero() {
estimatedPoints, err := estimateLegacyHourlyP95Points(db, summary, oldest, newest)
if err != nil {
return summary, err
}
summary.EstimatedPoints = estimatedPoints
retentionEnd := time.Now().UTC()
if newest.After(retentionEnd) {
retentionEnd = newest
@@ -228,6 +210,9 @@ func DeleteLegacyMonitoringBefore(db *gorm.DB, cutoff time.Time) (LegacyMonitori
}
func MigrateLegacyMonitoring(ctx context.Context, db *gorm.DB, s *metric.Store, progress func(LegacyMonitoringProgress)) (LegacyMonitoringStats, error) {
if err := metricstore.EnsureBuiltinMetricDefinitions(ctx, s); err != nil {
return LegacyMonitoringStats{}, fmt.Errorf("ensure built-in metric definitions: %w", err)
}
return migrateLegacyMonitoringTables(ctx, db, s, progress)
}
@@ -301,15 +286,13 @@ func migrateLegacyMonitoringTables(ctx context.Context, db *gorm.DB, s *metric.S
if progress != nil {
emit("", 0, 0)
}
for _, table := range []string{"records", "records_long_term"} {
n, err := migrateLegacyRecordTable(ctx, s, db, table, emit)
if err != nil {
return stats, err
}
stats.Records += n
n, err := migrateLegacyRecordTables(ctx, s, db, []string{"records", "records_long_term"}, emit)
if err != nil {
return stats, err
}
stats.Records += n
n, err := migrateLegacyGPURecordTable(ctx, s, db, "gpu_records", emit)
n, err = migrateLegacyGPURecordTable(ctx, s, db, "gpu_records", emit)
if err != nil {
return stats, err
}
@@ -324,56 +307,49 @@ func migrateLegacyMonitoringTables(ctx context.Context, db *gorm.DB, s *metric.S
return stats, nil
}
func migrateLegacyRecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
if !db.Migrator().HasTable(table) {
return 0, nil
}
func migrateLegacyRecordTables(ctx context.Context, s *metric.Store, db *gorm.DB, tables []string, progress legacyBatchProgress) (int64, error) {
var existing []string
var total int64
if err := db.Table(table).Count(&total).Error; err != nil {
return 0, fmt.Errorf("count legacy %s: %w", table, err)
for _, table := range tables {
if !db.Migrator().HasTable(table) {
continue
}
var count int64
if err := db.Table(table).Count(&count).Error; err != nil {
return 0, fmt.Errorf("count legacy %s: %w", table, err)
}
if count > 0 {
existing = append(existing, table)
total += count
}
}
if total == 0 {
if len(existing) == 0 {
return 0, nil
}
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
var migrated int64
var lastRowID int64
for {
select {
case <-ctx.Done():
return migrated, ctx.Err()
default:
}
var rows []legacyRecordRow
q := db.Table(table).Select("rowid AS row_id, *").Where("rowid > ?", lastRowID).Order("rowid ASC").Limit(legacyMonitoringBatchSize)
if err := q.Find(&rows).Error; err != nil {
return migrated, fmt.Errorf("read legacy %s: %w", table, err)
}
if len(rows) == 0 {
break
}
points := make([]metric.Point, 0, len(rows)*19)
for i := range rows {
points = append(points, recordToPoints(rows[i].Record)...)
}
if err := s.WriteBatch(ctx, points); err != nil {
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
}
last := rows[len(rows)-1]
lastRowID = last.RowID
migrated += int64(len(rows))
if progress != nil {
progress(table, int64(len(rows)), int64(len(points)))
parts := make([]string, 0, len(existing))
for _, table := range existing {
projection, err := legacyRecordProjection(db, table)
if err != nil {
return 0, err
}
parts = append(parts, projection)
}
query := strings.Join(parts, " UNION ALL ") + " ORDER BY client ASC, time ASC"
rows, err := db.Raw(query).Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy record tables: %w", err)
}
defer rows.Close()
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, strings.Join(existing, ","))
migrated, err := migrateLegacyStream(ctx, db, s, rows, "records", func() *models.Record { return &models.Record{} }, func(value models.Record) []metric.Point {
return recordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy record tables: %w", err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, strings.Join(existing, ","))
return migrated, nil
}
@@ -390,43 +366,19 @@ func migrateLegacyGPURecordTable(ctx context.Context, s *metric.Store, db *gorm.
return 0, nil
}
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
var migrated int64
var lastRowID int64
for {
select {
case <-ctx.Done():
return migrated, ctx.Err()
default:
}
var rows []legacyGPURecordRow
q := db.Table(table).Select("rowid AS row_id, *").Where("rowid > ?", lastRowID).Order("rowid ASC").Limit(legacyMonitoringBatchSize)
if err := q.Find(&rows).Error; err != nil {
return migrated, fmt.Errorf("read legacy %s: %w", table, err)
}
if len(rows) == 0 {
break
}
points := make([]metric.Point, 0, len(rows)*4)
for i := range rows {
points = append(points, gpuRecordToPoints(rows[i].GPURecord)...)
}
if err := s.WriteBatch(ctx, points); err != nil {
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
}
last := rows[len(rows)-1]
lastRowID = last.RowID
migrated += int64(len(rows))
if progress != nil {
progress(table, int64(len(rows)), int64(len(points)))
}
rows, err := db.Table(table).Select("*").Order("client ASC, device_index ASC, device_name ASC, time ASC").Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy %s: %w", table, err)
}
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
defer rows.Close()
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, table)
migrated, err := migrateLegacyStream(ctx, db, s, rows, table, func() *models.GPURecord { return &models.GPURecord{} }, func(value models.GPURecord) []metric.Point {
return gpuRecordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy %s: %w", table, err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, table)
return migrated, nil
}
@@ -443,46 +395,254 @@ func migrateLegacyPingRecordTable(ctx context.Context, s *metric.Store, db *gorm
return 0, nil
}
log.Printf("[legacy-migration] importing %d rows from %s", total, table)
var migrated int64
var lastRowID int64
rows, err := db.Table(table).Select("*").Order("client ASC, task_id ASC, time ASC").Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy %s: %w", table, err)
}
defer rows.Close()
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, table)
migrated, err := migrateLegacyStream(ctx, db, s, rows, table, func() *models.PingRecord { return &models.PingRecord{} }, func(value models.PingRecord) []metric.Point {
return pingRecordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy %s: %w", table, err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, table)
return migrated, nil
}
for {
func migrateLegacyStream[T any](ctx context.Context, db *gorm.DB, s *metric.Store, rows *sql.Rows, table string, newValue func() *T, toPoints func(T) []metric.Point, progress legacyBatchProgress) (int64, error) {
aggregator := &legacyHourlyP95Aggregator{store: s}
var migrated, batchRows, batchPoints int64
emit := func(force bool) {
if progress != nil && (batchRows > 0 || force && batchPoints > 0) {
progress(table, batchRows, batchPoints)
}
batchRows = 0
batchPoints = 0
}
for rows.Next() {
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)
value := newValue()
if err := db.ScanRows(rows, value); err != nil {
return migrated, err
}
if len(rows) == 0 {
break
written, err := aggregator.Add(ctx, toPoints(*value))
if err != nil {
return migrated, err
}
points := make([]metric.Point, 0, len(rows)*2)
for i := range rows {
points = append(points, pingRecordToPoints(rows[i].PingRecord)...)
}
if err := s.WriteBatch(ctx, points); err != nil {
return migrated, fmt.Errorf("write legacy %s to metric store: %w", table, err)
}
last := rows[len(rows)-1]
lastRowID = last.RowID
migrated += int64(len(rows))
if progress != nil {
progress(table, int64(len(rows)), int64(len(points)))
migrated++
batchRows++
batchPoints += written
if batchRows >= legacyMonitoringBatchSize {
emit(false)
}
}
log.Printf("[legacy-migration] imported %d rows from %s", migrated, table)
if err := rows.Err(); err != nil {
return migrated, err
}
written, err := aggregator.Flush(ctx)
if err != nil {
return migrated, err
}
batchPoints += written
emit(true)
return migrated, nil
}
func legacyRecordProjection(db *gorm.DB, table string) (string, error) {
columnTypes, err := db.Migrator().ColumnTypes(table)
if err != nil {
return "", fmt.Errorf("inspect legacy %s columns: %w", table, err)
}
available := make(map[string]struct{}, len(columnTypes))
for _, column := range columnTypes {
available[strings.ToLower(column.Name())] = struct{}{}
}
for _, required := range []string{"client", "time"} {
if _, ok := available[required]; !ok {
return "", fmt.Errorf("legacy %s is missing required column %s", table, required)
}
}
columns := []string{
"client", "time", "cpu", "gpu", "ram", "swap", "load", "disk",
"net_in", "net_out", "net_total_up", "net_total_down", "traffic_up",
"traffic_down", "process", "connections", "connections_udp",
}
projection := make([]string, 0, len(columns))
for _, column := range columns {
if _, ok := available[column]; ok {
projection = append(projection, column)
} else {
projection = append(projection, "0 AS "+column)
}
}
return "SELECT " + strings.Join(projection, ", ") + " FROM " + table, nil
}
func (a *legacyHourlyP95Aggregator) Add(ctx context.Context, points []metric.Point) (int64, error) {
var written int64
for _, point := range points {
bucket := point.Timestamp.UTC().Truncate(legacyMonitoringInterval)
partition, err := legacyPointPartition(point)
if err != nil {
return written, err
}
if a.groups != nil && (partition != a.partitionKey || !bucket.Equal(a.bucket)) {
if partition == a.partitionKey && bucket.Before(a.bucket) {
return written, fmt.Errorf("legacy points are not ordered within series: %s before %s", bucket, a.bucket)
}
n, err := a.Flush(ctx)
written += n
if err != nil {
return written, err
}
}
if a.groups == nil {
a.partitionKey = partition
a.bucket = bucket
a.groups = make(map[string]*legacyP95Group)
}
group := a.groups[point.MetricName]
if group == nil {
group = &legacyP95Group{
metricName: point.MetricName,
entityID: point.EntityID,
tags: cloneLegacyTags(point.Tags),
}
a.groups[point.MetricName] = group
}
group.values = append(group.values, point.Value)
}
return written, nil
}
func (a *legacyHourlyP95Aggregator) Flush(ctx context.Context) (int64, error) {
if len(a.groups) == 0 {
return 0, nil
}
names := make([]string, 0, len(a.groups))
for name := range a.groups {
names = append(names, name)
}
sort.Strings(names)
points := make([]metric.Point, 0, len(names))
for _, name := range names {
group := a.groups[name]
points = append(points, metric.Point{
MetricName: group.metricName,
EntityID: group.entityID,
Timestamp: a.bucket,
Value: legacyP95(group.values),
Tags: group.tags,
})
}
if err := a.store.WriteBatch(ctx, points); err != nil {
return 0, err
}
a.partitionKey = ""
a.bucket = time.Time{}
a.groups = nil
return int64(len(points)), nil
}
func legacyP95(values []float64) float64 {
if len(values) == 0 {
return 0
}
sort.Float64s(values)
if len(values) == 1 {
return values[0]
}
position := 0.95 * float64(len(values)-1)
lower := int(math.Floor(position))
upper := int(math.Ceil(position))
if lower == upper {
return values[lower]
}
weight := position - float64(lower)
return values[lower]*(1-weight) + values[upper]*weight
}
func legacyPointPartition(point metric.Point) (string, error) {
tags, err := json.Marshal(point.Tags)
if err != nil {
return "", fmt.Errorf("encode legacy point tags: %w", err)
}
return point.EntityID + "\x00" + string(tags), nil
}
func cloneLegacyTags(tags map[string]string) map[string]string {
if len(tags) == 0 {
return nil
}
cloned := make(map[string]string, len(tags))
for key, value := range tags {
cloned[key] = value
}
return cloned
}
func estimateLegacyHourlyP95Points(db *gorm.DB, summary LegacyMonitoringSummary, oldest, newest time.Time) (int64, error) {
type estimate struct {
Count int64 `gorm:"column:count"`
}
countSeries := func(query string) (int64, error) {
var result estimate
if err := db.Raw("SELECT COUNT(*) AS count FROM (" + query + ")").Scan(&result).Error; err != nil {
return 0, err
}
return result.Count, nil
}
firstHour := oldest.UTC().Truncate(legacyMonitoringInterval)
lastHour := newest.UTC().Truncate(legacyMonitoringInterval)
hours := int64(lastHour.Sub(firstHour)/legacyMonitoringInterval) + 1
if hours < 1 {
hours = 1
}
var recordParts []string
for _, table := range []string{"records", "records_long_term"} {
if db.Migrator().HasTable(table) {
recordParts = append(recordParts, "SELECT client FROM "+table)
}
}
var total int64
if len(recordParts) > 0 {
series, err := countSeries(strings.Join(recordParts, " UNION "))
if err != nil {
return 0, fmt.Errorf("estimate load series: %w", err)
}
total += minLegacyBuckets(summary.LoadRows, series*hours) * 15
}
if db.Migrator().HasTable("gpu_records") {
series, err := countSeries("SELECT client, device_index, device_name FROM gpu_records GROUP BY client, device_index, device_name")
if err != nil {
return 0, fmt.Errorf("estimate GPU series: %w", err)
}
total += minLegacyBuckets(summary.GPURows, series*hours) * 4
}
if db.Migrator().HasTable("ping_records") {
series, err := countSeries("SELECT client, task_id FROM ping_records GROUP BY client, task_id")
if err != nil {
return 0, fmt.Errorf("estimate ping series: %w", err)
}
total += minLegacyBuckets(summary.LatencyRows, series*hours) * 2
}
return total, nil
}
func minLegacyBuckets(sourceRows, estimatedBuckets int64) int64 {
if sourceRows < estimatedBuckets {
return sourceRows
}
return estimatedBuckets
}
func recordToPoints(rec models.Record) []metric.Point {
ts := rec.Time
entityID := rec.Client
@@ -490,13 +650,9 @@ func recordToPoints(rec models.Record) []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)},
@@ -553,7 +709,7 @@ func dropLegacyMonitoringTables(db *gorm.DB) error {
if !db.Migrator().HasTable(table) {
continue
}
log.Printf("[legacy-migration] dropping legacy table %s", table)
logger.Infof("migration", "[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)
}
@@ -4,13 +4,14 @@ import (
"context"
"database/sql"
"errors"
"math"
"path/filepath"
"testing"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -57,7 +58,7 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
if summary.LoadRows != 2 || summary.GPURows != 1 || summary.LatencyRows != 2 || summary.MonitoringRows != 5 {
t.Fatalf("unexpected legacy monitoring summary: %#v", summary)
}
if summary.EstimatedPoints != 46 || summary.RetentionDays < 1 {
if summary.EstimatedPoints != 21 || summary.RetentionDays < 1 {
t.Fatalf("unexpected legacy point estimate or retention: %#v", summary)
}
@@ -81,7 +82,7 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
}); err != nil {
t.Fatalf("migrate legacy monitoring with progress: %v", err)
}
if lastProgress.SourceRowsDone != 5 || lastProgress.SourceRowsTotal != 5 || lastProgress.WrittenPoints != 46 {
if lastProgress.SourceRowsDone != 5 || lastProgress.SourceRowsTotal != 5 || lastProgress.WrittenPoints != 21 {
t.Fatalf("unexpected final migration progress: %#v", lastProgress)
}
@@ -99,15 +100,16 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
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)})
hour := base.Truncate(time.Hour)
cpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricCPU, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour)})
if err != nil {
t.Fatalf("query cpu points: %v", err)
}
if len(cpuPoints) != 2 || cpuPoints[0].Value != 12.5 || cpuPoints[1].Value != 22.5 {
if len(cpuPoints) != 1 || math.Abs(cpuPoints[0].Value-22) > 1e-9 || !cpuPoints[0].Timestamp.Equal(hour) {
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"}})
gpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"device_index": "0"}})
if err != nil {
t.Fatalf("query gpu points: %v", err)
}
@@ -115,19 +117,19 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
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"}})
pingPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLatency, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), 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 {
if len(pingPoints) != 1 || math.Abs(pingPoints[0].Value-34.15) > 1e-9 || !pingPoints[0].Timestamp.Equal(hour) {
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"}})
pingLossPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLoss, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), 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 {
if len(pingLossPoints) != 1 || math.Abs(pingLossPoints[0].Value-0.95) > 1e-9 || !pingLossPoints[0].Timestamp.Equal(hour) {
t.Fatalf("unexpected ping loss points: %#v", pingLossPoints)
}
@@ -152,6 +154,96 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
}
}
func TestLegacyHourlyP95PreservesHoursAndTaggedSeries(t *testing.T) {
ctx := context.Background()
metricDB, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "hourly-p95.db"))+"?mode=rwc")
if err != nil {
t.Fatalf("open metric db: %v", err)
}
store, err := metric.Open(ctx, metric.SQLite("", metric.WithDB(metricDB)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
_ = metricDB.Close()
})
if err := store.UpsertMetric(ctx, metric.Definition{Name: "test.p95", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create test metric: %v", err)
}
base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)
aggregator := &legacyHourlyP95Aggregator{store: store}
inputs := [][]metric.Point{
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Minute), Value: 10, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(2 * time.Minute), Value: 20, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Hour), Value: 30, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Minute), Value: 100, Tags: map[string]string{"task_id": "2"}}},
}
for _, points := range inputs {
if _, err := aggregator.Add(ctx, points); err != nil {
t.Fatalf("add aggregate input: %v", err)
}
}
if _, err := aggregator.Flush(ctx); err != nil {
t.Fatalf("flush aggregate input: %v", err)
}
taskOne, err := store.Query(ctx, metric.Query{
MetricName: "test.p95", EntityID: "node-a", Start: base.Add(-time.Second), End: base.Add(2 * time.Hour),
Tags: map[string]string{"task_id": "1"}, Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query task one: %v", err)
}
if len(taskOne) != 2 || math.Abs(taskOne[0].Value-19.5) > 1e-9 || taskOne[1].Value != 30 {
t.Fatalf("unexpected task one aggregates: %#v", taskOne)
}
taskTwo, err := store.Query(ctx, metric.Query{
MetricName: "test.p95", EntityID: "node-a", Start: base.Add(-time.Second), End: base.Add(2 * time.Hour),
Tags: map[string]string{"task_id": "2"}, Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query task two: %v", err)
}
if len(taskTwo) != 1 || taskTwo[0].Value != 100 || !taskTwo[0].Timestamp.Equal(base) {
t.Fatalf("unexpected task two aggregates: %#v", taskTwo)
}
}
func TestLegacyRecordProjectionFillsMissingMetricColumns(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "minimal-legacy.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
t.Fatalf("open minimal legacy db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
} else {
t.Fatalf("minimal legacy sql db: %v", err)
}
if err := db.Exec("CREATE TABLE records_minimal (client TEXT NOT NULL, time DATETIME NOT NULL, cpu REAL NOT NULL)").Error; err != nil {
t.Fatalf("create minimal legacy table: %v", err)
}
base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)
if err := db.Exec("INSERT INTO records_minimal (client, time, cpu) VALUES (?, ?, ?)", "node-a", base, 42.5).Error; err != nil {
t.Fatalf("insert minimal legacy row: %v", err)
}
projection, err := legacyRecordProjection(db, "records_minimal")
if err != nil {
t.Fatalf("build legacy projection: %v", err)
}
var record models.Record
if err := db.Raw(projection).Scan(&record).Error; err != nil {
t.Fatalf("scan projected legacy row: %v", err)
}
if record.Client != "node-a" || !record.Time.Equal(base) || record.Cpu != 42.5 {
t.Fatalf("unexpected projected record: %#v", record)
}
if record.ConnectionsUdp != 0 || record.NetTotalDown != 0 {
t.Fatalf("missing metric columns were not zero-filled: %#v", record)
}
}
func TestDeleteLegacyMonitoringBeforeOnlyRemovesOldHistory(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "cleanup.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
@@ -3,13 +3,13 @@ package migrations
import (
"encoding/json"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"reflect"
"strings"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
appconfig "github.com/komari-monitor/komari/internal/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -176,7 +176,7 @@ func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
logger.InfoArgs("migration", "[>0.1.4] Rebuilding LoadNotification table....")
return db.Migrator().DropTable(&models.LoadNotification{})
}
return nil
@@ -187,7 +187,7 @@ func migrateLegacyOidcConfig(db *gorm.DB) error {
return nil
}
log.Println("[>1.0.2] Merge OidcProvider table....")
logger.InfoArgs("migration", "[>1.0.2] Merge OidcProvider table....")
var oldData struct {
OAuthClientID string `gorm:"column:o_auth_client_id"`
OAuthClientSecret string `gorm:"column:o_auth_client_secret"`
@@ -224,7 +224,7 @@ func migrateLegacyMessageSenderConfig(db *gorm.DB) error {
return nil
}
log.Println("[>1.0.2] Migrate MessageSender configuration....")
logger.InfoArgs("migration", "[>1.0.2] Migrate MessageSender configuration....")
var oldData struct {
TelegramBotToken string `gorm:"column:telegram_bot_token"`
TelegramChatID string `gorm:"column:telegram_chat_id"`
@@ -308,7 +308,7 @@ func saveLegacyMessageSenderConfig(db *gorm.DB, name string, config map[string]i
}
func migrateLegacyConfigToItems(db *gorm.DB) error {
log.Println("[>1.1.4] Moving legacy config data...")
logger.InfoArgs("migration", "[>1.1.4] Moving legacy config data...")
var oldData legacyConfig
if err := db.Order("id desc").First(&oldData).Error; err != nil {
@@ -371,7 +371,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
return nil
}
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
logger.InfoArgs("migration", "[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
if err := db.AutoMigrate(&models.Client{}); err != nil {
return err
}
@@ -384,7 +384,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
logger.Errorf("migration", "Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
@@ -416,7 +416,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
return fmt.Errorf("backup legacy ClientInfo table: %w", err)
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
logger.InfoArgs("migration", "Data migration completed, old table has been backed up as client_infos_backup")
return nil
}
@@ -7,7 +7,7 @@ import (
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
appconfig "github.com/komari-monitor/komari/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -3,14 +3,14 @@ package migrations
import (
"database/sql"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"math"
"os"
"strconv"
"strings"
"time"
appconfig "github.com/komari-monitor/komari/pkg/config"
appconfig "github.com/komari-monitor/komari/internal/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -90,7 +90,7 @@ func migrateLegacyTimestampColumns(db *gorm.DB) error {
return err
}
if converted > 0 {
log.Printf("Converted %d legacy timestamp values to explicit UTC", converted)
logger.Infof("migration", "Converted %d legacy timestamp values to explicit UTC", converted)
}
return nil
}
@@ -204,7 +204,7 @@ func legacyTimestampLocation() *time.Location {
}
location, err := time.LoadLocation(name)
if err != nil {
log.Printf("Legacy timezone %q cannot be loaded; interpreting old timestamps as UTC: %v", name, err)
logger.Infof("migration", "Legacy timezone %q cannot be loaded; interpreting old timestamps as UTC: %v", name, err)
return time.UTC
}
return location
File renamed without changes.
@@ -1,9 +1,9 @@
package corn
package scheduler
import (
"context"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"strconv"
"strings"
"sync"
@@ -172,7 +172,7 @@ func (m *Manager) run(ctx context.Context, name string, s schedule, runImmediate
nextTick := s.Next(time.Now())
if nextTick.IsZero() {
log.Printf("corn job %s has no next run time", name)
logger.Warnf("scheduler", "corn job %s has no next run time", name)
return
}
timer := time.NewTimer(time.Until(nextTick))
@@ -203,7 +203,7 @@ func resetTimer(timer *time.Timer, duration time.Duration) {
func safeRun(ctx context.Context, name string, fn Func) {
defer func() {
if r := recover(); r != nil {
log.Printf("corn job %s panic: %v", name, r)
logger.Errorf("scheduler", "corn job %s panic: %v", name, r)
}
}()
@@ -1,4 +1,4 @@
package corn
package scheduler
import (
"testing"
+47
View File
@@ -0,0 +1,47 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/config"
)
// cleanupFunc is a cleanup action run during shutdown.
type cleanupFunc struct {
name string
fn func(ctx context.Context) error
}
// App owns the server lifecycle. Individual phases live in focused files:
// bootstrap, metric store, providers, guides, router, and runtime.
type App struct {
listenAddr string
settings *config.Settings
engine *gin.Engine
server *http.Server
reload *ReloadManager
dbReady bool
oauthReady bool
metricStoreCleanupAdded bool
cleanups []cleanupFunc
}
// Options configures the process-wide application runtime.
type Options struct {
ListenAddr string
}
// New constructs an empty application. Initialization happens in the
// explicit lifecycle phases called by the command entrypoint.
func New(options Options) *App {
return &App{listenAddr: options.ListenAddr, reload: NewReloadManager()}
}
// addCleanup registers a cleanup action. Shutdown executes actions in LIFO
// order, mirroring resource initialization order.
func (a *App) addCleanup(name string, fn func(ctx context.Context) error) {
a.cleanups = append(a.cleanups, cleanupFunc{name: name, fn: fn})
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRetryMetricStoreConnectionStopsAfterRecovery(t *testing.T) {
wantErr := errors.New("temporary connection failure")
attempts := 0
err := retryMetricStoreConnection(3, 0, func() error {
attempts++
if attempts < 3 {
return wantErr
}
return nil
})
if err != nil {
t.Fatalf("retry returned error after recovery: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestGuideNoRouteRejectsUnknownAPIAndRedirectsPages(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(noStoreAPIResponses())
router.NoRoute(guideNoRoute("/install", "Not found in install mode", nil))
apiResponse := httptest.NewRecorder()
router.ServeHTTP(apiResponse, httptest.NewRequest(http.MethodGet, "/api/unknown", nil))
if apiResponse.Code != http.StatusNotFound {
t.Fatalf("API status = %d, want %d", apiResponse.Code, http.StatusNotFound)
}
if got := apiResponse.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("API cache control = %q, want no-store", got)
}
pageResponse := httptest.NewRecorder()
router.ServeHTTP(pageResponse, httptest.NewRequest(http.MethodGet, "/stale-guide", nil))
if pageResponse.Code != http.StatusTemporaryRedirect {
t.Fatalf("page status = %d, want %d", pageResponse.Code, http.StatusTemporaryRedirect)
}
if got := pageResponse.Header().Get("Location"); got != "/install" {
t.Fatalf("redirect location = %q, want /install", got)
}
}
func TestRetryMetricStoreConnectionReturnsLastError(t *testing.T) {
wantErr := errors.New("connection unavailable")
attempts := 0
err := retryMetricStoreConnection(3, 0, func() error {
attempts++
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"context"
"fmt"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/resourceprobe"
"github.com/komari-monitor/komari/utils"
logger "github.com/komari-monitor/komari/utils/log"
)
// Bootstrap initializes the data directory, primary database, and settings.
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.SetVersionID(utils.CurrentVersion + "-" + utils.VersionHash)
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() })
gin.SetMode(gin.ReleaseMode)
lowResourceMode, err := ensureLowResourceModeDefault()
if err != nil {
return fmt.Errorf("failed to initialize low resource mode: %w", err)
}
if err := dbcore.ConfigureLowResourceMode(lowResourceMode); err != nil {
return err
}
settings, err := config.GetManyAs[config.Settings]()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
a.settings = settings
return nil
}
func ensureLowResourceModeDefault() (bool, error) {
values, err := config.GetMany(map[string]any{config.LowResourceModeKey: nil})
if err != nil {
return false, err
}
if saved, ok := values[config.LowResourceModeKey]; ok {
enabled, ok := saved.(bool)
if !ok {
return false, fmt.Errorf("%s must be a boolean", config.LowResourceModeKey)
}
return enabled, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()
result := resourceprobe.Detect(ctx, "./data")
if err := config.Set(config.LowResourceModeKey, result.LowResource); err != nil {
return false, err
}
logger.Infof("server", "Low resource mode auto-detection: enabled=%t memory=%dMiB disk_free=%dMiB cpu=%.0fops/s random_write=%.2fMiB/s iops=%.0f reasons=%v", result.LowResource, result.MemoryBytes/(1024*1024), result.DiskFreeBytes/(1024*1024), result.CPUOpsPerSecond, result.WriteBytesPerSecond/(1024*1024), result.WriteIOPS, result.Reasons)
return result.LowResource, nil
}
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/web/api"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/web/security"
)
type guideController interface {
Activate()
Deactivate()
Register(*gin.Engine)
Done() <-chan struct{}
}
type guideServerConfig struct {
pagePath string
missingAPI string
logMessage string
requireIdentity bool
restrictedStatic bool
}
// runGuideServer hosts one temporary, narrowly scoped guide. It deliberately
// constructs a new Gin engine for each mode so normal routes never leak into
// installation, upgrade, or recovery flows.
func (a *App) runGuideServer(controller guideController, cfg guideServerConfig) (bool, error) {
controller.Activate()
defer controller.Deactivate()
r := gin.New()
r.Use(logger.GinLogger(), logger.GinRecovery(), noStoreAPIResponses())
if cfg.requireIdentity {
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware(), api.IdentityMiddleware())
}
controller.Register(r)
serveStatic := frontendpublic.Static
if cfg.restrictedStatic {
serveStatic = frontendpublic.StaticRestricted
}
serveStatic(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(guideNoRoute(cfg.pagePath, cfg.missingAPI, handlers))
})
server := &http.Server{Addr: a.listenAddr, Handler: r}
a.engine = r
a.server = server
serverErr := make(chan error, 1)
logger.Infof("server", cfg.logMessage, a.listenAddr)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case err := <-serverErr:
return false, fmt.Errorf("listen in guide mode: %w", err)
case <-quit:
return false, a.Shutdown()
case <-controller.Done():
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return false, fmt.Errorf("stop guide server: %w", err)
}
a.server = nil
a.engine = nil
return true, nil
}
}
func noStoreAPIResponses() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.Header("Cache-Control", "no-store")
}
c.Next()
}
}
func guideNoRoute(pagePath, missingAPI string, handlers []gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
requestPath := c.Request.URL.Path
if strings.HasPrefix(requestPath, "/api") {
api.RespondError(c, http.StatusNotFound, missingAPI)
return
}
if c.Request.Method == http.MethodGet && requestPath != pagePath && filepath.Ext(requestPath) == "" {
c.Redirect(http.StatusTemporaryRedirect, pagePath)
return
}
for _, handler := range handlers {
handler(c)
if c.IsAborted() {
return
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/migrations"
installweb "github.com/komari-monitor/komari/web/install"
recoveryweb "github.com/komari-monitor/komari/web/recovery"
upgradeweb "github.com/komari-monitor/komari/web/update"
)
// InstallRequired reports whether the instance still needs the first-run guide.
func (a *App) InstallRequired() (bool, error) {
var count int64
if err := dbcore.GetDBInstance().Model(&models.User{}).Count(&count).Error; err != nil {
return false, err
}
return count == 0, nil
}
func (a *App) LegacyUpgradeRequired() (bool, migrations.LegacyMonitoringSummary, error) {
return migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
}
// RunInstallGuide exposes only first-run installation APIs. It intentionally
// does not mount authentication or normal application routes.
func (a *App) RunInstallGuide() (bool, error) {
return a.runGuideServer(installweb.NewController(dbcore.GetDBInstance()), guideServerConfig{
pagePath: installweb.PagePath,
missingAPI: "Not found in install mode",
logMessage: "First-run installation guide is available on %s",
})
}
// RunMetricStoreRecovery keeps login available while exposing only the
// administrator-protected metric-store recovery API.
func (a *App) RunMetricStoreRecovery(initialErr error) (bool, error) {
a.initOAuth()
return a.runGuideServer(recoveryweb.NewController(initialErr, metricStoreReconnectAttempts), guideServerConfig{
pagePath: recoveryweb.PagePath,
missingAPI: "Not found in database recovery mode",
logMessage: "Metric store recovery is available on %s",
requireIdentity: true,
restrictedStatic: true,
})
}
// RunLegacyUpgrade keeps login available while migration is in progress.
func (a *App) RunLegacyUpgrade(summary migrations.LegacyMonitoringSummary) (bool, error) {
a.initOAuth()
return a.runGuideServer(upgradeweb.NewController(dbcore.GetDBInstance(), summary), guideServerConfig{
pagePath: upgradeweb.PagePath,
missingAPI: "Not found in upgrade mode",
logMessage: "Legacy monitoring data requires the 1.2.7 upgrade wizard on %s",
requireIdentity: true,
})
}
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"fmt"
"time"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/internal/config"
logger "github.com/komari-monitor/komari/utils/log"
)
const (
metricStoreReconnectAttempts = 3
metricStoreReconnectInterval = 5 * time.Second
)
// ConnectMetricStore performs one connection attempt and registers cleanup
// only after a store has actually opened.
func (a *App) ConnectMetricStore() error {
if err := metricstore.InitializeStore(); err != nil {
return fmt.Errorf("failed to initialize metric store: %s", redactMetricStoreError(err))
}
if !a.metricStoreCleanupAdded {
a.addCleanup("metric-store", metricstore.CloseStoreContext)
a.metricStoreCleanupAdded = true
}
return nil
}
func redactMetricStoreError(err error) string {
if err == nil {
return ""
}
dsn := ""
if cfg, cfgErr := config.GetManyAs[metricstore.MetricStoreConfig](); cfgErr == nil {
dsn = cfg.DSN
}
return metricstore.RedactConnectionError(err.Error(), dsn)
}
// ConnectMetricStoreWithRetry retries the monitoring database connection.
func (a *App) ConnectMetricStoreWithRetry() error {
attempt := 0
err := retryMetricStoreConnection(metricStoreReconnectAttempts, metricStoreReconnectInterval, func() error {
attempt++
err := a.ConnectMetricStore()
if err != nil {
logger.Warn("server", "Metric store connection attempt failed", "attempt", attempt, "max_attempts", metricStoreReconnectAttempts, "error", err)
}
return err
})
if err == nil && attempt > 1 {
logger.Infof("server", "Metric store connection recovered on attempt %d/%d", attempt, metricStoreReconnectAttempts)
}
return err
}
func retryMetricStoreConnection(attempts int, interval time.Duration, connect func() error) error {
if attempts < 1 {
return fmt.Errorf("metric store retry attempts must be positive")
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
time.Sleep(interval)
}
if err := connect(); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
// InitStores opens the metric store and starts its report batcher. Historical
// data migrations are started only through an explicit administrator action.
func (a *App) InitStores() error {
if err := a.ConnectMetricStore(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize metric store: %v", err))
return err
}
metricstore.StartReportBatcher()
a.addCleanup("metric-report-batcher", metricstore.StopReportBatcher)
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package server
import (
"context"
"fmt"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/oauth"
)
// InitProviders initializes providers needed by the normal application.
func (a *App) InitProviders() error {
a.initOAuth()
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error { return geoip.Shutdown() })
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error { return messageSender.Shutdown() })
return nil
}
// initOAuth initializes OAuth once. Restricted authenticated guides need it
// before the normal provider initialization phase.
func (a *App) initOAuth() {
if a.oauthReady {
return
}
if err := oauth.Initialize(); err != nil {
logger.Errorf("server", "Failed to initialize OAuth provider: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
}
a.oauthReady = true
a.addCleanup("oauth", func(context.Context) error { return oauth.Shutdown() })
}
+4 -4
View File
@@ -1,9 +1,9 @@
package cmd
package server
import (
"log"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
)
// reloadHandler 是单个配置热重载处理器。
@@ -60,7 +60,7 @@ func (m *ReloadManager) Start() {
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)
logger.Errorf("reload", "config reload handler %q panicked: %v", h.name, r)
}
}()
h.handler(event)
+195
View File
@@ -0,0 +1,195 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"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/metricstore"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
recoveryweb "github.com/komari-monitor/komari/web/recovery"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
)
// StartBackground starts scheduled work after all stores are ready.
func (a *App) StartBackground() error {
registerScheduledWork()
a.addCleanup("scheduler", func(context.Context) error {
scheduler.StopAll()
return nil
})
return nil
}
func (a *App) registerReloadHandlers(cors *security.CorsController) {
a.reload.Register("oauth-provider", func(event config.ConfigEvent) {
if ok, providerName := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if providerName == "" || providerName == "none" {
providerName = "github"
}
oidcProvider, err := database.GetOidcConfigByName(providerName)
if err != nil {
logger.Errorf("server", "Failed to get OIDC provider config: %v", err)
return
}
logger.Infof("server", "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))
}
}
})
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()
}
})
a.reload.Register("cors", func(event config.ConfigEvent) { cors.Update(event) })
}
// BuildRouter constructs the normal application router and starts reloads.
func (a *App) BuildRouter() error {
r := gin.New()
r.Use(logger.GinLogger(), logger.GinRecovery())
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware(), api.IdentityMiddleware(), api.PrivateSiteMiddleware(), noStoreAPIResponses())
// The recovery UI belongs only to its temporary restricted listener.
r.GET(recoveryweb.PagePath, func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, "/")
})
router.Register(r)
a.registerReloadHandlers(cors)
a.reload.Start()
a.engine = r
return nil
}
// Run starts the normal HTTP server and blocks until shutdown or fatal error.
func (a *App) Run() error {
a.server = &http.Server{Addr: a.listenAddr, Handler: a.engine}
serverErr := make(chan error, 1)
logger.Infof("server", "Starting server on %s ...", a.listenAddr)
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case err := <-serverErr:
a.onFatal(err)
return fmt.Errorf("listen: %w", err)
case <-quit:
return a.Shutdown()
}
}
// Shutdown stops HTTP first, then releases registered resources in LIFO order.
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()
if a.server != nil {
if err := a.server.Shutdown(ctx); err != nil {
logger.Infof("server", "HTTP server forced to shutdown: %v", err)
}
}
a.runCleanups(ctx)
return nil
}
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()
a.runCleanups(ctx)
}
func (a *App) runCleanups(ctx context.Context) {
for i := len(a.cleanups) - 1; i >= 0; i-- {
cleanup := a.cleanups[i]
if err := cleanup.fn(ctx); err != nil {
logger.Errorf("server", "cleanup %q failed: %v", cleanup.name, err)
}
}
}
func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload load notification schedule:", err)
}
if err := scheduler.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
logger.ErrorArgs("server", "Failed to add cleanup scheduled task:", err)
}
if err := scheduler.AddContextFunc("metrics:compact", "@every 5m", true, compactMetricStore); err != nil {
logger.ErrorArgs("server", "Failed to add metric compact scheduled task:", err)
}
if err := scheduler.AddFunc("notifier:traffic", "@every 1m", notifier.CheckTraffic); err != nil {
logger.ErrorArgs("server", "Failed to add traffic notification task:", err)
}
if err := scheduler.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
logger.ErrorArgs("server", "Failed to add expire notification task:", err)
}
notifier.InitTrafficReportSchedule()
}
const taskResultRetentionDays = 30
func cleanupScheduledData() {
before := time.Now().UTC().Add(-24 * time.Hour * taskResultRetentionDays)
if err := tasks.ClearTaskResultsByTimeBefore(before); err != nil {
logger.Errorf("server", "Failed to clean expired task results: %v", err)
}
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func compactMetricStore(ctx context.Context) {
compactCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
written, err := metricstore.Compact(compactCtx, time.Now().UTC())
if errors.Is(err, metricstore.ErrCompactInProgress) {
return
}
if err != nil {
logger.Errorf("server", "Failed to compact metric store after writing %d rollup buckets: %v", written, err)
return
}
if written > 0 {
logger.Infof("server", "Metric store compacted %d rollup buckets", written)
}
}
+4 -5
View File
@@ -1,22 +1,21 @@
package main
import (
"log"
"log/slog"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
logger "github.com/komari-monitor/komari/utils/log"
)
func main() {
if utils.VersionHash == "unknown" {
logutil.SetupGlobalLogger(slog.LevelDebug)
logger.Setup(slog.LevelDebug)
} else {
logutil.SetupGlobalLogger(slog.LevelInfo)
logger.Setup(slog.LevelInfo)
}
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
logger.Infof("server", "Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
+29
View File
@@ -91,6 +91,9 @@ func (s *Store) ReclaimSpace(ctx context.Context) error {
if s.closed || s.db == nil {
return ErrClosed
}
if _, err := s.cleanupOrphanedMetricData(ctx); err != nil {
return err
}
switch s.cfg.Driver {
case DriverSQLite:
@@ -123,6 +126,32 @@ func (s *Store) ReclaimSpace(ctx context.Context) error {
}
}
// cleanupOrphanedMetricData removes rows whose metric definition no longer
// exists. It runs immediately before an explicit space-reclaim operation so
// the physical rewrite returns the freed pages to the filesystem in the same
// maintenance window.
func (s *Store) cleanupOrphanedMetricData(ctx context.Context) (int64, error) {
var deleted int64
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if table == "" {
continue
}
result, err := s.db.ExecContext(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s WHERE %s.name = %s.metric_name)`,
table, s.tables.definitions, s.tables.definitions, table,
))
if err != nil {
return deleted, fmt.Errorf("metric: delete orphaned rows from %s: %w", table, err)
}
count, err := result.RowsAffected()
if err != nil {
return deleted, err
}
deleted += count
}
return deleted, nil
}
func (s *Store) sqliteStorageSize(ctx context.Context) (int64, error) {
rows, err := s.db.QueryContext(ctx, "PRAGMA database_list")
if err != nil {
+44
View File
@@ -3,11 +3,13 @@ package metric
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestSQLiteStorageSizeAndReclaimSpace(t *testing.T) {
@@ -73,6 +75,48 @@ func TestSQLiteStorageSizeAndReclaimSpace(t *testing.T) {
}
}
func TestCleanupOrphanedMetricData(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
if err := store.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create known definition: %v", err)
}
if err := store.writeBatch(ctx, store.db, []Point{{
MetricName: "orphan", EntityID: "node-1", Timestamp: time.Now().UTC(), Value: 1,
}}); err != nil {
t.Fatalf("seed orphan point: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(
`INSERT INTO %s (metric_name, entity_id, tags_hash, tags, resolution_nano, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
store.tables.rollups,
), "orphan", "node-1", "", "{}", int64(time.Minute), time.Now().UTC().UnixNano(), 1, 1, 1, 1, 1, 1, time.Now().UTC().UnixNano(), 1, time.Now().UTC().UnixNano(), []byte{}, time.Now().UTC().UnixNano()); err != nil {
t.Fatalf("seed orphan rollup: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(
`INSERT INTO %s (metric_name, watermark_nano, updated_at) VALUES (?, ?, ?)`, store.tables.watermarks,
), "orphan", time.Now().UTC().UnixNano(), time.Now().UTC().UnixNano()); err != nil {
t.Fatalf("seed orphan watermark: %v", err)
}
deleted, err := store.cleanupOrphanedMetricData(ctx)
if err != nil {
t.Fatalf("clean orphaned metric data: %v", err)
}
if deleted != 3 {
t.Fatalf("deleted rows = %d, want 3", deleted)
}
for _, table := range []string{store.tables.points, store.tables.rollups, store.tables.watermarks} {
var count int
if err := store.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE metric_name = ?`, table), "orphan").Scan(&count); err != nil {
t.Fatalf("count orphan rows in %s: %v", table, err)
}
if count != 0 {
t.Fatalf("orphan rows remain in %s: %d", table, count)
}
}
}
func TestMaintenanceMappings(t *testing.T) {
tables := tables{
definitions: "Metric_definitions",
+19 -6
View File
@@ -221,13 +221,22 @@ type rollupBucket struct {
//
// newRollupBucket 创建一个空的 rollup 累加器。
func newRollupBucket(compression float64) *rollupBucket {
return &rollupBucket{
return newRollupBucketWithDigest(compression, true)
}
// newRollupBucketWithDigest creates a query or compaction accumulator. Query
// paths only need the digest for percentile aggregations.
func newRollupBucketWithDigest(compression float64, includeDigest bool) *rollupBucket {
bucket := &rollupBucket{
min: 0,
max: 0,
firstTS: 0,
lastTS: 0,
digest: NewTDigest(compression),
}
if includeDigest {
bucket.digest = NewTDigest(compression)
}
return bucket
}
// addPoint folds a raw observation into the bucket.
@@ -255,7 +264,9 @@ func (b *rollupBucket) addPoint(value float64, tsNano int64) {
b.count++
b.sum += value
b.sumSq += value * value
b.digest.Add(value, 1)
if b.digest != nil {
b.digest.Add(value, 1)
}
}
// mergeStored folds a finer rollup row (already-summarized) into this coarser
@@ -289,10 +300,12 @@ func (b *rollupBucket) mergeStored(o *rollupBucket) {
b.count += o.count
b.sum += o.sum
b.sumSq += o.sumSq
if b.digest == nil {
b.digest = NewTDigest(defaultTDigestCompression)
if o.digest != nil {
if b.digest == nil {
b.digest = NewTDigest(defaultTDigestCompression)
}
b.digest.Merge(o.digest)
}
b.digest.Merge(o.digest)
}
// value computes the requested aggregation from the bucket summaries. ok=false
+48 -37
View File
@@ -56,6 +56,7 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
q := query.Query.normalized()
comp := s.cfg.RollupPolicy.compression()
needDigest := isPercentile(query.Aggregation)
// Read rollup buckets at this resolution that are FULLY contained in the
// inclusive window [Start, End] (entity and tag filters pushed into SQL),
@@ -66,11 +67,11 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
// trimmed to the window and is therefore excluded. Callers that need every
// sample in a sub-bucket window must align the window to resolution
// boundaries (or query raw points).
rows, err := s.scanRollupRowsContained(ctx, q.MetricName, q.EntityID, q.Tags, resolution, q.Start, q.End)
rows, err := s.scanRollupRowsContained(ctx, q.MetricName, q.EntityID, q.Tags, resolution, q.Start, q.End, needDigest)
if err != nil {
return nil, err
}
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries)
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries, needDigest)
out, err := rollupGroupsToPoints(groups, query)
if err != nil {
@@ -167,7 +168,7 @@ func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query Aggreg
// end 边界为闭区间,以匹配原始查询语义(raw 使用 ts <= end);因此最后覆盖纳秒
// 恰为 end 的桶仍算被包含。零宽窗口(start == end)不包含任何完整桶,返回空结果,
// 需要亚桶精度的调用方应查询原始点。
func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityID string, tags map[string]string, resolution time.Duration, start, end time.Time) ([]storedRollup, error) {
func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityID string, tags map[string]string, resolution time.Duration, start, end time.Time, needDigest bool) ([]storedRollup, error) {
resNano := resolution.Nanoseconds()
startNano := start.UTC().UnixNano()
endNano := end.UTC().UnixNano()
@@ -179,7 +180,7 @@ func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityI
if upper < lower {
return nil, nil // window narrower than one bucket: nothing is contained
}
rows, err := s.scanRollupRowsBetween(ctx, metricName, entityID, tags, resNano, lower, upper)
rows, err := s.scanRollupRowsBetween(ctx, metricName, entityID, tags, resNano, lower, upper, needDigest)
if err != nil {
return nil, err
}
@@ -195,7 +196,7 @@ func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityI
// scanRollupRowsBetween 读取某分辨率下桶起点落在闭区间
// [lowerBucket, upperBucket] 内的 rollup 行,并可把实体和标签过滤下推到 SQL。
// 它是包含扫描和混合扫描共用的 SQL 原语;桶窗口语义由调用方通过传入的边界决定。
func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID string, tags map[string]string, resNano, lowerBucket, upperBucket int64) ([]storedRollup, error) {
func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID string, tags map[string]string, resNano, lowerBucket, upperBucket int64, needDigest bool) ([]storedRollup, error) {
args := []any{metricName, resNano, lowerBucket, upperBucket}
parts := []string{
"metric_name = " + s.dialect.placeholder(1),
@@ -211,28 +212,29 @@ func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID
args = append(args, tags[k])
parts = append(parts, s.dialect.jsonExtractEquals("tags", k, s.dialect.placeholder(len(args))))
}
sqlText := fmt.Sprintf(
`SELECT entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest
FROM %s WHERE %s ORDER BY bucket_nano ASC`,
s.tables.rollups, strings.Join(parts, " AND "),
)
columns := "entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts"
if needDigest {
columns += ", digest"
}
sqlText := fmt.Sprintf("SELECT %s FROM %s WHERE %s ORDER BY bucket_nano ASC", columns, s.tables.rollups, strings.Join(parts, " AND "))
rows, err := s.reader().QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanStoredRollups(rows)
return scanStoredRollups(rows, needDigest)
}
// foldRollupRows folds stored rollup rows into interval-wide output buckets,
// merging summaries (including the t-digest) per bucket. groups may be nil, in
// which case a fresh map is allocated; passing an existing map lets a caller
// accumulate rollup and raw contributions into the same output buckets.
// foldRollupRows folds stored rollup rows into interval-wide output buckets.
// Percentile queries merge t-digests; other aggregations only carry their exact
// summaries. groups may be nil, in which case a fresh map is allocated; passing
// an existing map lets a caller accumulate rollup and raw contributions into
// the same output buckets.
//
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶,按桶合并摘要
// (包括 t-digest)。groups 可为 nil,此时会分配新 map;传入已有 map 可让调用方
// 把 rollup 和 raw 的贡献累加到同一批输出桶中。
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64, preserveSeries bool) map[rollupKey]*rollupBucket {
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶。百分位查询会
// 按桶合并 t-digest,其他聚合只携带精确摘要。groups 可为 nil,此时会分配新 map;
// 传入已有 map 可让调用方把 rollup 和 raw 的贡献累加到同一批输出桶中。
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64, preserveSeries, needDigest bool) map[rollupKey]*rollupBucket {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
}
@@ -242,7 +244,7 @@ func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, int
key := foldedRollupKey(r.entityID, r.bucketData.tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucket(comp)
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = r.bucketData.tagsHash
b.tagsJSON = r.bucketData.tagsJSON
groups[key] = b
@@ -261,7 +263,7 @@ func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, int
// foldRawPoints 将原始点折叠进 interval 宽的输出桶,把每个观测值加入对应桶的
// 累加器。它与 foldRollupRows 共用桶 map,因此混合查询可以把旧 rollup 半边和
// 近期 raw 半边合并到同一批输出桶中并一起聚合(跨边界的 count/avg/百分位正确)。
func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval time.Duration, comp float64, preserveSeries bool) (map[rollupKey]*rollupBucket, error) {
func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval time.Duration, comp float64, preserveSeries, needDigest bool) (map[rollupKey]*rollupBucket, error) {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
}
@@ -277,7 +279,7 @@ func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval
key := foldedRollupKey(p.EntityID, tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucket(comp)
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = tagsHash
b.tagsJSON = tagsJSON
groups[key] = b
@@ -314,16 +316,16 @@ func rollupTagsFromJSON(tagsJSON string) (map[string]string, error) {
return cloneStringMap(tags), nil
}
// scanStoredRollups reconstructs storedRollup rows (including tag identity and
// the decoded t-digest) from a result set whose columns are, in order:
// scanStoredRollups reconstructs storedRollup rows from a result set whose
// columns are, in order:
// entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val,
// first_val, first_ts, last_val, last_ts, digest.
// first_val, first_ts, last_val, last_ts, and optionally digest.
//
// scanStoredRollups 从结果集中还原 storedRollup 行(包括标签身份和解码后的
// t-digest)。结果集的列顺序必须是:entity_id、tags_hash、tags、bucket_nano、
// scanStoredRollups 从结果集中还原 storedRollup 行。结果集的列顺序必须是:
// entity_id、tags_hash、tags、bucket_nano、
// count、sum、sum_sq、min_val、max_val、first_val、first_ts、last_val、
// last_ts、digest。
func scanStoredRollups(rows *sql.Rows) ([]storedRollup, error) {
// last_ts,以及可选的 digest。
func scanStoredRollups(rows *sql.Rows, needDigest bool) ([]storedRollup, error) {
var out []storedRollup
for rows.Next() {
var (
@@ -336,12 +338,20 @@ func scanStoredRollups(rows *sql.Rows) ([]storedRollup, error) {
firstTS, lastTS int64
digestBlob []byte
)
if err := rows.Scan(&eid, &tagsHash, &rawTags, &bucketNano, &count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS, &digestBlob); err != nil {
scanArgs := []any{&eid, &tagsHash, &rawTags, &bucketNano, &count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS}
if needDigest {
scanArgs = append(scanArgs, &digestBlob)
}
if err := rows.Scan(scanArgs...); err != nil {
return nil, err
}
td, err := DecodeTDigest(digestBlob)
if err != nil {
return nil, err
var digest *TDigest
if needDigest {
var err error
digest, err = DecodeTDigest(digestBlob)
if err != nil {
return nil, err
}
}
tagsJSON, err := rawTagsToJSON(rawTags)
if err != nil {
@@ -355,7 +365,7 @@ func scanStoredRollups(rows *sql.Rows) ([]storedRollup, error) {
min: minV, max: maxV,
firstVal: firstV, firstTS: firstTS,
lastVal: lastV, lastTS: lastTS,
digest: td,
digest: digest,
tagsHash: tagsHash,
tagsJSON: tagsJSON,
},
@@ -581,6 +591,7 @@ func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary
func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQuery, boundary, rollupEnd time.Time, tier *RollupTier) ([]AggregatePoint, error) {
q := query.Query.normalized()
comp := s.cfg.RollupPolicy.compression()
needDigest := isPercentile(query.Aggregation)
resNano := tier.Interval.Nanoseconds()
startNano := q.Start.UnixNano()
endNano := q.End.UnixNano()
@@ -592,11 +603,11 @@ func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQu
// contains compacted points before splitAt; the raw half starts at splitAt.
upperBucket := floorDivNano(rollupEnd.UTC().UnixNano(), resNano)
if upperBucket >= startNano {
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket)
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket, needDigest)
if err != nil {
return nil, err
}
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries)
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries, needDigest)
}
// Recent half: raw points in [splitAt, End]. Reuse the raw Query path so the
@@ -612,7 +623,7 @@ func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQu
if err != nil {
return nil, err
}
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries); err != nil {
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries, needDigest); err != nil {
return nil, err
}
}
+1 -1
View File
@@ -530,7 +530,7 @@ func (s *Store) scanRollupRows(ctx context.Context, q querier, metricName string
return nil, err
}
defer rows.Close()
return scanStoredRollups(rows)
return scanStoredRollups(rows, true)
}
// mergeRollupBucketsTx merges a consumed raw-data delta into the affected
+84
View File
@@ -26,6 +26,50 @@ func newRollupStore(t *testing.T, policy RollupPolicy) *Store {
return store
}
func TestLatestBeforeUsesRawAndRollupData(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "counter", Type: TypeCounter, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "counter", EntityID: "node", Timestamp: base, Value: 10},
{MetricName: "counter", EntityID: "node", Timestamp: base.Add(5 * time.Minute), Value: 20},
}); err != nil {
t.Fatalf("write old counters: %v", err)
}
if _, err := s.Compact(ctx, base.Add(30*time.Minute)); err != nil {
t.Fatalf("compact counters: %v", err)
}
point, ok, err := s.LatestBefore(ctx, "counter", "node", base.Add(4*time.Minute))
if err != nil {
t.Fatalf("latest rollup before boundary: %v", err)
}
if !ok || point.Value != 10 || !point.Timestamp.Equal(base) {
t.Fatalf("unexpected bounded rollup point: %#v, found=%v", point, ok)
}
recent := base.Add(29 * time.Minute)
if err := s.Write(ctx, Point{MetricName: "counter", EntityID: "node", Timestamp: recent, Value: 30}); err != nil {
t.Fatalf("write recent counter: %v", err)
}
point, ok, err = s.LatestBefore(ctx, "counter", "node", base.Add(30*time.Minute))
if err != nil {
t.Fatalf("latest raw before boundary: %v", err)
}
if !ok || point.Value != 30 || !point.Timestamp.Equal(recent) {
t.Fatalf("unexpected latest raw point: %#v, found=%v", point, ok)
}
}
// TestArbitraryPercentileOverRaw verifies arbitrary percentile (pxx)
// aggregation end-to-end over raw points.
//
@@ -72,6 +116,46 @@ func TestArbitraryPercentileOverRaw(t *testing.T) {
}
}
func TestAggregateRollupSkipsDigestForNonPercentile(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 2 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "latency", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "latency", EntityID: "node", Timestamp: base.Add(10 * time.Second), Value: 10},
{MetricName: "latency", EntityID: "node", Timestamp: base.Add(20 * time.Second), Value: 20},
}); err != nil {
t.Fatalf("write points: %v", err)
}
if _, err := s.Compact(ctx, base.Add(4*time.Minute)); err != nil {
t.Fatalf("compact: %v", err)
}
if _, err := s.db.ExecContext(ctx, fmt.Sprintf("UPDATE %s SET digest = ?", s.tables.rollups), []byte("invalid")); err != nil {
t.Fatalf("corrupt digest: %v", err)
}
query := Query{MetricName: "latency", EntityID: "node", Start: base, End: base.Add(time.Minute)}
avg, err := s.AggregateRollup(ctx, AggregateQuery{Query: query, Aggregation: AggAvg, Interval: time.Minute}, time.Minute)
if err != nil {
t.Fatalf("average should not read digest: %v", err)
}
if len(avg) != 1 || avg[0].Value != 15 {
t.Fatalf("average = %#v, want one bucket with value 15", avg)
}
if _, err := s.AggregateRollup(ctx, AggregateQuery{Query: query, Aggregation: Pxx(4), Interval: time.Minute}, time.Minute); err == nil {
t.Fatal("percentile query should reject an invalid digest")
}
}
// percentileSortedRange computes an exact percentile for an integer range.
//
// percentileSortedRange 为整数范围计算精确百分位。
+228 -18
View File
@@ -295,6 +295,8 @@ func (s *Store) configureSQLite(ctx context.Context, db *sql.DB) error {
}
if s.cfg.SQLite.TempStoreMemory {
pragmas = append(pragmas, "PRAGMA temp_store = MEMORY")
} else {
pragmas = append(pragmas, "PRAGMA temp_store = FILE")
}
for _, pragma := range pragmas {
@@ -542,9 +544,55 @@ func (s *Store) DeleteMetric(ctx context.Context, name string) error {
return tx.Commit()
}
// UpdateMetricRetention updates one metric's retention policy without deleting
// its existing data. A value of zero disables subsequent persistence. Negative
// values are invalid.
func (s *Store) UpdateMetricRetention(ctx context.Context, name string, retentionDays int) (Definition, error) {
if err := s.ensureOpen(); err != nil {
return Definition{}, err
}
name = strings.TrimSpace(name)
if name == "" {
return Definition{}, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if retentionDays < 0 {
return Definition{}, fmt.Errorf("%w: retention days cannot be negative", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Definition{}, err
}
defer func() { _ = tx.Rollback() }()
updatedAt := time.Now().UTC().UnixNano()
result, err := tx.ExecContext(ctx,
fmt.Sprintf(`UPDATE %s SET retention_days = %s, updated_at = %s WHERE name = %s`,
s.tables.definitions, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3)),
retentionDays, updatedAt, name,
)
if err != nil {
return Definition{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return Definition{}, err
}
if affected == 0 {
return Definition{}, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
if err := tx.Commit(); err != nil {
return Definition{}, err
}
return s.GetMetric(ctx, name)
}
// SetMetricRetention updates one metric's retention policy. A value of zero
// disables persistence for that metric and atomically removes its raw and
// rollup data. Negative values are invalid.
// disables persistence for that metric and removes its raw and rollup data.
// Negative values are invalid.
func (s *Store) SetMetricRetention(ctx context.Context, name string, retentionDays int) (Definition, error) {
if err := s.ensureOpen(); err != nil {
return Definition{}, err
@@ -583,18 +631,13 @@ func (s *Store) SetMetricRetention(ctx context.Context, name string, retentionDa
return Definition{}, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
if retentionDays == 0 {
for _, table := range []string{s.tables.points, s.tables.rollups} {
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return Definition{}, err
}
}
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)), name,
); err != nil {
return Definition{}, err
}
}
if err := tx.Commit(); err != nil {
return Definition{}, err
@@ -602,6 +645,81 @@ func (s *Store) SetMetricRetention(ctx context.Context, name string, retentionDa
return s.GetMetric(ctx, name)
}
// DeleteMetricData removes all raw, rollup, and watermark data for one metric.
func (s *Store) DeleteMetricData(ctx context.Context, name string) error {
if err := s.ensureOpen(); err != nil {
return err
}
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteMetricDataIfDisabled removes a metric's data only while its retention
// policy is still disabled. It prevents a delayed background cleanup from
// deleting data after an administrator has re-enabled the metric.
func (s *Store) DeleteMetricDataIfDisabled(ctx context.Context, name string) (bool, error) {
if err := s.ensureOpen(); err != nil {
return false, err
}
name = strings.TrimSpace(name)
if name == "" {
return false, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
var retentionDays int
if err := tx.QueryRowContext(ctx,
fmt.Sprintf(`SELECT retention_days FROM %s WHERE name = %s`, s.tables.definitions, s.dialect.placeholder(1)),
name,
).Scan(&retentionDays); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
return false, err
}
if retentionDays != 0 {
return false, nil
}
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
// DeleteEntity deletes all raw and rollup data for one entity across every metric.
//
// DeleteEntity 删除某个实体在所有指标下的原始点和 rollup 数据。
@@ -742,25 +860,26 @@ func (s *Store) WriteBatch(ctx context.Context, points []Point) error {
return tx.Commit()
}
// filterDisabledMetricPoints preserves the historic behavior for unknown
// metrics while dropping points whose known definition has zero retention.
// filterDisabledMetricPoints rejects points without a definition and drops
// points whose definition has zero retention. Definitions are the source of
// truth for a metric's retention and lifecycle, so accepting an unknown name
// would create data that compaction and retention cleanup cannot manage.
func (s *Store) filterDisabledMetricPoints(ctx context.Context, points []Point) ([]Point, error) {
defs, err := s.ListMetrics(ctx)
if err != nil {
return nil, err
}
disabled := make(map[string]struct{})
definitions := make(map[string]Definition, len(defs))
for _, def := range defs {
if def.RetentionDays == 0 {
disabled[def.Name] = struct{}{}
}
}
if len(disabled) == 0 {
return points, nil
definitions[def.Name] = def
}
filtered := make([]Point, 0, len(points))
for _, point := range points {
if _, ok := disabled[point.MetricName]; !ok {
def, ok := definitions[point.MetricName]
if !ok {
return nil, fmt.Errorf("%w: metric %q", ErrNotFound, point.MetricName)
}
if def.RetentionDays > 0 {
filtered = append(filtered, point)
}
}
@@ -985,6 +1104,97 @@ func (s *Store) Latest(ctx context.Context, metricName, entityID string, limit i
return out, rows.Err()
}
// LatestBefore returns the newest retained point before an exclusive boundary.
// It checks both indexed raw points and rollup last-values so callers do not
// need to scan and aggregate an entire retention window to restore a counter.
func (s *Store) LatestBefore(ctx context.Context, metricName, entityID string, before time.Time) (Point, bool, error) {
if err := s.ensureOpen(); err != nil {
return Point{}, false, err
}
if strings.TrimSpace(metricName) == "" {
return Point{}, false, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if strings.TrimSpace(entityID) == "" {
return Point{}, false, fmt.Errorf("%w: entity id is required", ErrInvalidArgument)
}
if before.IsZero() {
return Point{}, false, fmt.Errorf("%w: before time is required", ErrInvalidArgument)
}
beforeNano := before.UTC().UnixNano()
var latest Point
found := false
rawSQL := fmt.Sprintf(
`SELECT metric_name, entity_id, ts_nano, value, tags, labels FROM %s
WHERE metric_name = %s AND entity_id = %s AND ts_nano < %s
ORDER BY ts_nano DESC LIMIT 1`,
s.tables.points, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
)
var rawTags, rawLabels any
var rawNano int64
err := s.reader().QueryRowContext(ctx, rawSQL, metricName, entityID, beforeNano).Scan(
&latest.MetricName, &latest.EntityID, &rawNano, &latest.Value, &rawTags, &rawLabels,
)
if err == nil {
latest.Timestamp = time.Unix(0, rawNano).UTC()
latest.Tags, err = decodeMap(rawTags)
if err != nil {
return Point{}, false, err
}
latest.Labels, err = decodeMap(rawLabels)
if err != nil {
return Point{}, false, err
}
found = true
} else if !errors.Is(err, sql.ErrNoRows) {
return Point{}, false, err
}
if found {
rawCutoff := s.cfg.RollupPolicy.rawCutoff(before)
if rawCutoff.IsZero() || !latest.Timestamp.Before(rawCutoff) {
return latest, true, nil
}
}
for _, tier := range s.cfg.RollupPolicy.Tiers {
rollupSQL := fmt.Sprintf(
`SELECT tags, last_val, last_ts FROM %s
WHERE metric_name = %s AND entity_id = %s AND resolution_nano = %s AND last_ts < %s
ORDER BY last_ts DESC LIMIT 1`,
s.tables.rollups, s.dialect.placeholder(1), s.dialect.placeholder(2),
s.dialect.placeholder(3), s.dialect.placeholder(4),
)
var tags any
var value float64
var timestamp int64
err = s.reader().QueryRowContext(ctx, rollupSQL, metricName, entityID, tier.Interval.Nanoseconds(), beforeNano).Scan(
&tags, &value, &timestamp,
)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return Point{}, false, err
}
if found && timestamp <= latest.Timestamp.UnixNano() {
continue
}
decodedTags, err := decodeMap(tags)
if err != nil {
return Point{}, false, err
}
latest = Point{
MetricName: metricName,
EntityID: entityID,
Timestamp: time.Unix(0, timestamp).UTC(),
Value: value,
Tags: decodedTags,
}
found = true
}
return latest, found, nil
}
// Aggregate computes bucketed aggregates from raw points.
//
// Aggregate 对原始点执行分桶聚合,能下推到 SQL 的聚合会优先下推。
+58
View File
@@ -94,6 +94,9 @@ func TestWriteRejectsNonFiniteValues(t *testing.T) {
t.Fatalf("open sqlite store: %v", err)
}
defer store.Close()
if err := store.CreateMetric(ctx, Definition{Name: "bad", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
err := store.Write(ctx, Point{
@@ -108,6 +111,61 @@ func TestWriteRejectsNonFiniteValues(t *testing.T) {
}
}
func TestWriteBatchRequiresMetricDefinition(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
point := Point{MetricName: "custom.metric", EntityID: "server-1", Timestamp: time.Now().UTC(), Value: 1}
if err := store.Write(ctx, point); !errors.Is(err, ErrNotFound) {
t.Fatalf("write undefined metric error = %v, want ErrNotFound", err)
}
if err := store.CreateMetric(ctx, Definition{Name: point.MetricName, Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("register dynamic metric: %v", err)
}
if err := store.Write(ctx, point); err != nil {
t.Fatalf("write registered dynamic metric: %v", err)
}
}
func TestUpdateMetricRetentionDefersDisabledMetricCleanup(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
const metricName = "deferred.cleanup"
if err := store.CreateMetric(ctx, Definition{Name: metricName, Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
point := Point{MetricName: metricName, EntityID: "server-1", Timestamp: time.Now().UTC(), Value: 1}
if err := store.Write(ctx, point); err != nil {
t.Fatalf("write point: %v", err)
}
if _, err := store.UpdateMetricRetention(ctx, metricName, 0); err != nil {
t.Fatalf("disable metric retention: %v", err)
}
points, err := store.Query(ctx, Query{MetricName: metricName, EntityID: point.EntityID, Start: point.Timestamp.Add(-time.Second), End: point.Timestamp.Add(time.Second)})
if err != nil {
t.Fatalf("query retained point before cleanup: %v", err)
}
if len(points) != 1 {
t.Fatalf("points before cleanup = %d, want 1", len(points))
}
deleted, err := store.DeleteMetricDataIfDisabled(ctx, metricName)
if err != nil {
t.Fatalf("delete disabled metric data: %v", err)
}
if !deleted {
t.Fatal("expected disabled metric data to be deleted")
}
points, err = store.Query(ctx, Query{MetricName: metricName, EntityID: point.EntityID, Start: point.Timestamp.Add(-time.Second), End: point.Timestamp.Add(time.Second)})
if err != nil {
t.Fatalf("query cleaned metric: %v", err)
}
if len(points) != 0 {
t.Fatalf("points after cleanup = %d, want 0", len(points))
}
}
// TestSQLiteInDirCreatesDirectoryAndAppliesPragmas verifies SQLite file setup and PRAGMAs.
//
// TestSQLiteInDirCreatesDirectoryAndAppliesPragmas 验证 SQLite 文件初始化和 PRAGMA 设置。
+17
View File
@@ -0,0 +1,17 @@
//go:build linux
package resourceprobe
import (
"os"
"golang.org/x/sys/unix"
)
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_TRUNC|unix.O_DIRECT, 0o600)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(fd), path), nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !windows
package resourceprobe
import "os"
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
return os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
}
+29
View File
@@ -0,0 +1,29 @@
//go:build windows
package resourceprobe
import (
"os"
"golang.org/x/sys/windows"
)
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathPtr,
windows.GENERIC_READ|windows.GENERIC_WRITE,
0,
nil,
windows.CREATE_ALWAYS,
windows.FILE_ATTRIBUTE_TEMPORARY|windows.FILE_FLAG_NO_BUFFERING,
0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), path), nil
}
+172
View File
@@ -0,0 +1,172 @@
package resourceprobe
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"time"
"unsafe"
)
const (
minimumMemoryBytes = uint64(1536 * 1024 * 1024)
minimumDiskFreeBytes = uint64(15 * 1024 * 1024 * 1024)
minimumCPUOpsPerSecond = 30_000_000
minimumWriteBytesSecond = 5 * 1024 * 1024
minimumWriteIOPS = 1000
cpuBenchmarkDuration = 750 * time.Millisecond
diskBenchmarkDuration = 8 * time.Second
diskBenchmarkFileSize = int64(64 * 1024 * 1024)
diskBlockSize = int64(4096)
)
type Result struct {
LowResource bool
MemoryBytes uint64
DiskFreeBytes uint64
CPUOpsPerSecond float64
WriteBytesPerSecond float64
WriteIOPS float64
Reasons []string
}
// Detect measures only when the low-resource setting has never been saved.
// The caller should impose an outer deadline; the built-in benchmarks finish
// well inside one minute under normal conditions.
func Detect(ctx context.Context, dataDir string) Result {
result := Result{}
result.MemoryBytes, _ = systemMemoryBytes()
result.DiskFreeBytes, _ = systemDiskFreeBytes(dataDir)
if result.MemoryBytes > 0 && result.MemoryBytes < minimumMemoryBytes {
result.Reasons = append(result.Reasons, fmt.Sprintf("memory=%dMiB", result.MemoryBytes/(1024*1024)))
}
if result.DiskFreeBytes > 0 && result.DiskFreeBytes < minimumDiskFreeBytes {
result.Reasons = append(result.Reasons, fmt.Sprintf("disk_free=%dMiB", result.DiskFreeBytes/(1024*1024)))
}
if len(result.Reasons) > 0 {
result.LowResource = true
return result
}
result.CPUOpsPerSecond = benchmarkSingleCore(ctx, cpuBenchmarkDuration)
if result.CPUOpsPerSecond > 0 && result.CPUOpsPerSecond < minimumCPUOpsPerSecond {
result.Reasons = append(result.Reasons, fmt.Sprintf("cpu=%.0fops/s", result.CPUOpsPerSecond))
}
var err error
result.WriteBytesPerSecond, result.WriteIOPS, err = benchmarkRandomWrite(ctx, dataDir, diskBenchmarkDuration)
if err != nil {
result.Reasons = append(result.Reasons, "disk_benchmark="+err.Error())
} else {
if result.WriteBytesPerSecond < minimumWriteBytesSecond {
result.Reasons = append(result.Reasons, fmt.Sprintf("random_write=%.2fMiB/s", result.WriteBytesPerSecond/(1024*1024)))
}
if result.WriteIOPS < minimumWriteIOPS {
result.Reasons = append(result.Reasons, fmt.Sprintf("random_write=%.0fIOPS", result.WriteIOPS))
}
}
result.LowResource = len(result.Reasons) > 0
return result
}
var cpuBenchmarkSink uint64
func benchmarkSingleCore(ctx context.Context, duration time.Duration) float64 {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
start := time.Now()
deadline := start.Add(duration)
var operations uint64
x := uint64(0x9e3779b97f4a7c15)
for time.Now().Before(deadline) {
for range 100_000 {
x ^= x << 13
x ^= x >> 7
x ^= x << 17
operations++
}
select {
case <-ctx.Done():
cpuBenchmarkSink = x
return float64(operations) / time.Since(start).Seconds()
default:
}
}
cpuBenchmarkSink = x
return float64(operations) / time.Since(start).Seconds()
}
func benchmarkRandomWrite(ctx context.Context, dataDir string, duration time.Duration) (float64, float64, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return 0, 0, err
}
placeholder, err := os.CreateTemp(dataDir, ".komari-resource-probe-*")
if err != nil {
return 0, 0, err
}
name := placeholder.Name()
if err := placeholder.Close(); err != nil {
os.Remove(name)
return 0, 0, err
}
defer os.Remove(name)
f, err := openUnbufferedBenchmarkFile(name)
if err != nil {
return 0, 0, err
}
defer f.Close()
if err := f.Truncate(diskBenchmarkFileSize); err != nil {
return 0, 0, err
}
block := alignedBlock(int(diskBlockSize), int(diskBlockSize))
var state uint64 = 0x243f6a8885a308d3
blocks := uint64(diskBenchmarkFileSize / diskBlockSize)
start := time.Now()
deadline := start.Add(duration)
var writes uint64
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return 0, 0, ctx.Err()
default:
}
state ^= state << 13
state ^= state >> 7
state ^= state << 17
binary.LittleEndian.PutUint64(block, state)
offset := int64(state%blocks) * diskBlockSize
if _, err := f.WriteAt(block, offset); err != nil {
return 0, 0, err
}
writes++
}
elapsed := time.Since(start).Seconds()
if elapsed <= 0 || writes == 0 {
return 0, 0, errors.New("no writes completed")
}
if err := f.Sync(); err != nil {
return 0, 0, err
}
return float64(writes*uint64(diskBlockSize)) / elapsed, float64(writes) / elapsed, nil
}
func alignedBlock(size, alignment int) []byte {
raw := make([]byte, size+alignment)
address := uintptr(unsafe.Pointer(&raw[0]))
offset := int((uintptr(alignment) - address%uintptr(alignment)) % uintptr(alignment))
return raw[offset : offset+size]
}
func absoluteDir(path string) (string, error) {
if path == "" {
path = "."
}
return filepath.Abs(path)
}
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package resourceprobe
import "golang.org/x/sys/unix"
func systemMemoryBytes() (uint64, error) {
var info unix.Sysinfo_t
if err := unix.Sysinfo(&info); err != nil {
return 0, err
}
return uint64(info.Totalram) * uint64(info.Unit), nil
}
func systemDiskFreeBytes(path string) (uint64, error) {
abs, err := absoluteDir(path)
if err != nil {
return 0, err
}
var stat unix.Statfs_t
if err := unix.Statfs(abs, &stat); err != nil {
return 0, err
}
return stat.Bavail * uint64(stat.Bsize), nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux && !windows
package resourceprobe
import "errors"
func systemMemoryBytes() (uint64, error) {
return 0, errors.New("system memory probe is unsupported on this platform")
}
func systemDiskFreeBytes(string) (uint64, error) {
return 0, errors.New("disk space probe is unsupported on this platform")
}
+39
View File
@@ -0,0 +1,39 @@
package resourceprobe
import (
"context"
"os"
"testing"
"time"
)
func TestBenchmarkSingleCoreHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
if got := benchmarkSingleCore(ctx, time.Minute); got < 0 {
t.Fatalf("operations per second = %f", got)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("canceled benchmark took %s", elapsed)
}
}
func TestBenchmarkRandomWriteUsesAlignedUnbufferedIO(t *testing.T) {
dir := t.TempDir()
bytesPerSecond, iops, err := benchmarkRandomWrite(context.Background(), dir, 250*time.Millisecond)
if err != nil {
t.Fatalf("benchmark random write: %v", err)
}
if bytesPerSecond <= 0 || iops <= 0 {
t.Fatalf("invalid benchmark result: bytes/s=%f iops=%f", bytesPerSecond, iops)
}
t.Logf("unbuffered random write: %.2f MiB/s, %.0f IOPS", bytesPerSecond/(1024*1024), iops)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read benchmark directory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("benchmark left temporary files: %#v", entries)
}
}
+48
View File
@@ -0,0 +1,48 @@
//go:build windows
package resourceprobe
import (
"unsafe"
"golang.org/x/sys/windows"
)
var globalMemoryStatusEx = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func systemMemoryBytes() (uint64, error) {
status := memoryStatusEx{Length: uint32(unsafe.Sizeof(memoryStatusEx{}))}
result, _, err := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&status)))
if result == 0 {
return 0, err
}
return status.TotalPhys, nil
}
func systemDiskFreeBytes(path string) (uint64, error) {
abs, err := absoluteDir(path)
if err != nil {
return 0, err
}
pathPtr, err := windows.UTF16PtrFromString(abs)
if err != nil {
return 0, err
}
var available uint64
if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil {
return 0, err
}
return available, nil
}
+14 -16
View File
@@ -1,13 +1,13 @@
package geoip
import (
"log/slog"
"net"
"strings"
"time"
"unicode"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/patrickmn/go-cache"
)
@@ -66,49 +66,49 @@ func InitGeoIp() {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
slog.Error("Failed to initialize MaxMind GeoIP service: " + err.Error())
logger.Error("geoip", "failed to initialize MaxMind GeoIP service", "error", err)
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
} else {
CurrentProvider = &EmptyProvider{}
slog.Error("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
logger.Error("geoip", "failed to initialize MaxMind GeoIP service; using EmptyProvider")
}
case "ip-api":
NewCurrentProvider, err := NewIPAPIService()
if err != nil {
slog.Error("Failed to initialize ip-api service: " + err.Error())
logger.Error("geoip", "failed to initialize ip-api service", "error", err)
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
slog.Info("Using ip-api.com as GeoIP provider.")
logger.Info("geoip", "using GeoIP provider", "provider", "ip-api.com")
} else {
CurrentProvider = &EmptyProvider{}
slog.Info("Failed to initialize ip-api service, using EmptyProvider instead.")
logger.Warn("geoip", "failed to initialize ip-api service; using EmptyProvider")
}
case "geojs":
NewCurrentProvider, err := NewGeoJSService()
if err != nil {
slog.Error("Failed to initialize GeoJS service: " + err.Error())
logger.Error("geoip", "failed to initialize GeoJS service", "error", err)
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
slog.Info("Using geojs.io as GeoIP provider.")
logger.Info("geoip", "using GeoIP provider", "provider", "geojs.io")
} else {
CurrentProvider = &EmptyProvider{}
slog.Info("Failed to initialize GeoJS service, using EmptyProvider instead.")
logger.Warn("geoip", "failed to initialize GeoJS service; using EmptyProvider")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
slog.Error("Failed to initialize IPInfo service: " + err.Error())
logger.Error("geoip", "failed to initialize IPInfo service", "error", err)
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
slog.Info("Using ipinfo.io as GeoIP provider.")
logger.Info("geoip", "using GeoIP provider", "provider", "ipinfo.io")
} else {
CurrentProvider = &EmptyProvider{}
slog.Info("Failed to initialize IPInfo service, using EmptyProvider instead.")
logger.Warn("geoip", "failed to initialize IPInfo service; using EmptyProvider")
}
default:
CurrentProvider = &EmptyProvider{}
@@ -128,13 +128,11 @@ func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
cacheKey := providerName + ":" + ip.String()
if cachedInfo, found := geoCache.Get(cacheKey); found {
//log.Println("GeoIP cache hit for", cacheKey)
return cachedInfo.(*GeoInfo), nil
}
info, err := CurrentProvider.GetGeoInfo(ip)
if err == nil && info != nil {
//log.Println("GeoIP cache miss for", cacheKey)
geoCache.Set(cacheKey, info, cache.DefaultExpiration)
}
return info, err
@@ -144,7 +142,7 @@ func UpdateDatabase() error {
err := CurrentProvider.UpdateDatabase()
if err == nil {
geoCache.Flush()
slog.Info("GeoIP cache cleared due to database update.")
logger.Info("geoip", "cache cleared due to database update")
}
return err
}
+2 -2
View File
@@ -2,8 +2,8 @@ package geoip // 与 geoip.go 保持相同的包名,表示它们是同一个
import (
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"io"
"log"
"net"
"net/http"
"os"
@@ -176,6 +176,6 @@ func (s *MaxMindGeoIPService) Close() error {
return fmt.Errorf("error closing MaxMind database: %w", err)
}
}
log.Println("MaxMind GeoIP service closed.")
logger.InfoArgs("geoip", "MaxMind GeoIP service closed.")
return nil
}
+18 -48
View File
@@ -1,75 +1,45 @@
package log
package logger
import (
"fmt"
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
// GinLogger records completed requests. Query values are intentionally omitted
// because they commonly contain credentials or other sensitive data.
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
// 处理请求
c.Next()
// 计算延迟
latency := time.Since(start)
// 获取状态码
statusCode := c.Writer.Status()
statusCodeColored := func(code int) string {
if code >= 500 {
return Red("%d", code)
} else if code >= 400 {
return Yellow("%d", code)
} else if code >= 300 {
return Cyan("%d", code)
} else {
return Green("%d", code)
}
}(statusCode)
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
if query != "" {
msg += "?" + query
}
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
message := fmt.Sprintf("%d %s %s | %s | %s",
c.Writer.Status(), c.Request.Method, c.Request.URL.Path, c.ClientIP(), time.Since(start))
if len(c.Errors) > 0 {
msg += " | " + c.Errors.String()
message += " | " + c.Errors.String()
}
handler := slog.Default().Handler()
var level slog.Level
if statusCode >= 500 {
level = slog.LevelError
} else {
level = slog.LevelInfo
switch {
case c.Writer.Status() >= 500:
Error("gin", message)
case c.Writer.Status() >= 400:
Warn("gin", message)
default:
Info("gin", message)
}
r := slog.NewRecord(time.Now().UTC(), level, msg, 0)
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
handler.Handle(c.Request.Context(), r)
}
}
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
handler := slog.Default().Handler()
r := slog.NewRecord(time.Now().UTC(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GIN"))
handler.Handle(c.Request.Context(), r)
Error("http", "panic recovered",
"error", fmt.Sprint(err),
"method", c.Request.Method,
"path", c.Request.URL.Path,
)
c.AbortWithStatus(500)
}
}()
+35
View File
@@ -0,0 +1,35 @@
package logger
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/gin-gonic/gin"
)
func TestGinLoggerFormatsCompletedRequest(t *testing.T) {
previous := defaultLogger
var output bytes.Buffer
defaultLogger = slog.New(NewConsoleHandler(&output, slog.LevelDebug))
t.Cleanup(func() { defaultLogger = previous })
router := gin.New()
router.Use(GinLogger())
router.POST("/api/clients/v2/rpc", func(c *gin.Context) { c.Status(http.StatusOK) })
request := httptest.NewRequest(http.MethodPost, "/api/clients/v2/rpc?token=secret", nil)
request.RemoteAddr = "8.148.73.139:12345"
router.ServeHTTP(httptest.NewRecorder(), request)
line := output.String()
if !regexp.MustCompile(`(?:\x1b\[\d+m)?\[INFO/GIN\](?:\x1b\[0m)? 200 POST /api/clients/v2/rpc \| 8\.148\.73\.139 \| [^\r\n]+`).MatchString(line) {
t.Fatalf("unexpected Gin log format %q", line)
}
if bytes.Contains([]byte(line), []byte("token=secret")) {
t.Fatalf("Gin log must omit query string: %q", line)
}
}
+11 -29
View File
@@ -1,13 +1,11 @@
package log
package logger
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
komari_utils "github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"gorm.io/gorm/utils"
@@ -25,12 +23,10 @@ func NewGormLogger() *GormLogger {
return &GormLogger{
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
LogLevel: func(hash string) gormlogger.LogLevel {
if hash == "unknown" {
return gormlogger.Info
}
return gormlogger.Silent
}(komari_utils.VersionHash),
// Successful queries are high-volume and provide little diagnostic value
// in normal debug output. Callers investigating SQL can explicitly use
// LogMode(gormlogger.Info) for per-query traces.
LogLevel: gormlogger.Warn,
}
}
@@ -42,19 +38,19 @@ func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Info {
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
InfoContext(ctx, "gorm", fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Warn {
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
WarnContext(ctx, "gorm", fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Error {
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
ErrorContext(ctx, "gorm", fmt.Sprintf(msg, data...))
}
}
@@ -68,26 +64,12 @@ func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (stri
fileWithLineNum := utils.FileWithLineNum()
handler := slog.Default().Handler()
switch {
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now().UTC(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
ErrorContext(ctx, "gorm", "query failed", "elapsed", elapsed, "rows", rows, "sql", sql, "error", err, "source", fileWithLineNum)
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now().UTC(), slog.LevelWarn, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
WarnContext(ctx, "gorm", "slow query", "elapsed", elapsed, "rows", rows, "sql", sql, "source", fileWithLineNum)
case l.LogLevel >= gormlogger.Info:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now().UTC(), slog.LevelDebug, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
DebugContext(ctx, "gorm", "query completed", "elapsed", elapsed, "rows", rows, "sql", sql, "source", fileWithLineNum)
}
}
+162 -148
View File
@@ -1,180 +1,194 @@
package log
// Package logger provides the server's single runtime logging surface.
package logger
import (
"context"
"fmt"
"io"
stdlog "log"
"log/slog"
"os"
"runtime"
"time"
"strings"
"github.com/go-sql-driver/mysql"
)
func Green(format string, v ...interface{}) string {
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
var defaultLogger *slog.Logger
// ConsoleHandler keeps console logs concise while retaining structured fields.
// Example: 2026/07/21 10:51:52 [INFO/SERVER] Starting server on 0.0.0.0:25774 ...
type ConsoleHandler struct {
w io.Writer
level slog.Level
attrs []slog.Attr
groups []string
}
func Yellow(format string, v ...interface{}) string {
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
func NewConsoleHandler(w io.Writer, level slog.Level) *ConsoleHandler {
return &ConsoleHandler{w: w, level: level}
}
func Red(format string, v ...interface{}) string {
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
}
func Blue(format string, v ...interface{}) string {
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
}
func Cyan(format string, v ...interface{}) string {
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
}
func Gray(format string, v ...interface{}) string {
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
}
func White(format string, v ...interface{}) string {
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
}
type LogHandler struct {
w io.Writer
level slog.Level
group string
}
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
return &LogHandler{w: w, level: level, group: ""}
}
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
func (h *ConsoleHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
var file string
var line int
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
file = f.File
line = f.Line
}
timeStr := r.Time.UTC().Format("2006/01/02 15:04:05")
// 检查是否有分组信息(从 attributes 中获取)
group := h.group
r.Attrs(func(a slog.Attr) bool {
if a.Key == "_group" {
group = a.Value.String()
return false // 停止迭代
}
func (h *ConsoleHandler) Handle(_ context.Context, record slog.Record) error {
component := "APP"
attrs := make([]slog.Attr, 0, len(h.attrs)+record.NumAttrs())
attrs = append(attrs, h.attrs...)
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
levelStr := ""
switch r.Level {
case slog.LevelDebug:
if group != "" {
levelStr = Cyan("[DEBUG/%s]", group)
} else {
levelStr = Cyan("[DEBUG]")
var fields strings.Builder
for _, attr := range attrs {
if attr.Key == "component" {
component = strings.ToUpper(attr.Value.String())
continue
}
case slog.LevelInfo:
if group != "" {
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
} else {
levelStr = Green("[INFO]")
}
case slog.LevelWarn:
if group != "" {
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
} else {
levelStr = Yellow("[WARN]")
}
case slog.LevelError:
if group != "" {
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
} else {
levelStr = Red("[ERROR]")
key := attr.Key
if len(h.groups) > 0 {
key = strings.Join(h.groups, ".") + "." + key
}
fields.WriteByte(' ')
fields.WriteString(key)
fields.WriteByte('=')
fields.WriteString(formatValue(attr.Value))
}
var msg string
if file != "" {
msg = fmt.Sprintf("%s %s %s %s",
timeStr,
levelStr,
r.Message,
Gray(fmt.Sprintf("(%s:%d)", file, line)))
} else {
msg = fmt.Sprintf("%s %s %s",
timeStr,
levelStr,
r.Message)
}
r.Attrs(func(a slog.Attr) bool {
if a.Key != "_group" { // 跳过内部使用的 _group 属性
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
}
return true
})
msg += "\n"
_, err := h.w.Write([]byte(msg))
_, err := fmt.Fprintf(
h.w,
"%s %s[%s/%s]\033[0m %s%s\n",
record.Time.Local().Format("2006/01/02 15:04:05"),
levelColor(record.Level),
levelName(record.Level),
component,
record.Message,
fields.String(),
)
return err
}
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
func (h *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *h
clone.attrs = append(append([]slog.Attr(nil), h.attrs...), attrs...)
return &clone
}
func (h *LogHandler) WithGroup(name string) slog.Handler {
return h
}
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
func SetupGlobalLogger(level slog.Level) {
handler := NewHandler(os.Stdout, level)
logger := slog.New(handler)
// 设置 slog 默认 logger
slog.SetDefault(logger)
// 设置标准库 log 使用 slog
stdlog.SetOutput(os.Stdout)
stdlog.SetFlags(0) // 清除默认标志
stdlog.SetPrefix("")
// 替换标准库 log 的输出为 slog handler
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
}
// writerAdapter 将标准库 log 的输出适配到 slog
type writerAdapter struct {
handler slog.Handler
level slog.Level
}
func (w *writerAdapter) Write(p []byte) (n int, err error) {
msg := string(p)
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
msg = msg[:len(msg)-1]
func (h *ConsoleHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
var pcs [1]uintptr
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
r := slog.NewRecord(time.Now().UTC(), w.level, msg, pcs[0])
return len(p), w.handler.Handle(context.Background(), r)
clone := *h
clone.groups = append(append([]string(nil), h.groups...), name)
return &clone
}
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
func GetWriter() io.Writer {
handler := slog.Default().Handler()
return &writerAdapter{handler: handler, level: slog.LevelInfo}
func levelName(level slog.Level) string {
switch {
case level <= slog.LevelDebug:
return "DEBUG"
case level < slog.LevelWarn:
return "INFO"
case level < slog.LevelError:
return "WARN"
default:
return "ERROR"
}
}
func levelColor(level slog.Level) string {
switch {
case level <= slog.LevelDebug:
return "\033[36m" // 青
case level < slog.LevelWarn:
return "\033[32m" // 绿
case level < slog.LevelError:
return "\033[33m" // 黄
default:
return "\033[31m" // 红
}
}
func formatValue(value slog.Value) string {
var rendered string
if value.Kind() == slog.KindString {
rendered = value.String()
} else {
rendered = fmt.Sprint(value.Any())
}
if strings.ContainsAny(rendered, " \t\r\n\"") {
return fmt.Sprintf("%q", rendered)
}
return rendered
}
// Setup installs the process-wide console logger before the application starts.
func Setup(level slog.Level) {
defaultLogger = slog.New(NewConsoleHandler(os.Stdout, level))
slog.SetDefault(defaultLogger)
// The MySQL driver otherwise writes critical connection errors directly to stderr.
_ = mysql.SetLogger(mysqlDriverLogger{})
}
// mysqlDriverLogger bridges go-sql-driver/mysql's Logger interface to the
// process-wide structured logging surface.
type mysqlDriverLogger struct{}
func (mysqlDriverLogger) Print(args ...any) {
ErrorArgs("mysql", args...)
}
func logger() *slog.Logger {
if defaultLogger != nil {
return defaultLogger
}
return slog.Default()
}
func logArgs(component string, args []any) []any {
return append([]any{"component", component}, args...)
}
func Debug(component, message string, args ...any) {
logger().Debug(message, logArgs(component, args)...)
}
func Info(component, message string, args ...any) {
logger().Info(message, logArgs(component, args)...)
}
func Warn(component, message string, args ...any) {
logger().Warn(message, logArgs(component, args)...)
}
func Error(component, message string, args ...any) {
logger().Error(message, logArgs(component, args)...)
}
func Debugf(component, format string, args ...any) { Debug(component, fmt.Sprintf(format, args...)) }
func Infof(component, format string, args ...any) { Info(component, fmt.Sprintf(format, args...)) }
func Warnf(component, format string, args ...any) { Warn(component, fmt.Sprintf(format, args...)) }
func Errorf(component, format string, args ...any) { Error(component, fmt.Sprintf(format, args...)) }
func InfoArgs(component string, args ...any) { Info(component, fmt.Sprint(args...)) }
func WarnArgs(component string, args ...any) { Warn(component, fmt.Sprint(args...)) }
func ErrorArgs(component string, args ...any) { Error(component, fmt.Sprint(args...)) }
func Fatalf(component, format string, args ...any) {
Errorf(component, format, args...)
os.Exit(1)
}
func DebugContext(ctx context.Context, component, message string, args ...any) {
logger().DebugContext(ctx, message, logArgs(component, args)...)
}
func InfoContext(ctx context.Context, component, message string, args ...any) {
logger().InfoContext(ctx, message, logArgs(component, args)...)
}
func WarnContext(ctx context.Context, component, message string, args ...any) {
logger().WarnContext(ctx, message, logArgs(component, args)...)
}
func ErrorContext(ctx context.Context, component, message string, args ...any) {
logger().ErrorContext(ctx, message, logArgs(component, args)...)
}
+74
View File
@@ -0,0 +1,74 @@
package logger
import (
"bytes"
"log/slog"
"regexp"
"strings"
"testing"
"time"
gormlogger "gorm.io/gorm/logger"
)
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(value string) string {
return ansiEscape.ReplaceAllString(value, "")
}
func TestInfoWritesStructuredFields(t *testing.T) {
previous := defaultLogger
var output bytes.Buffer
defaultLogger = slog.New(NewConsoleHandler(&output, slog.LevelDebug))
t.Cleanup(func() { defaultLogger = previous })
Info("metricstore", "store initialized", "driver", "sqlite", "points", 12)
line := stripANSI(output.String())
if !regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} \[INFO/METRICSTORE\]`).MatchString(line) {
t.Fatalf("unexpected console log format %q", line)
}
for _, want := range []string{
"[INFO/METRICSTORE] store initialized",
"driver=sqlite",
"points=12",
} {
if !strings.Contains(line, want) {
t.Fatalf("expected %q in log output %q", want, line)
}
}
}
func TestMySQLDriverLoggerUsesStructuredConsoleFormat(t *testing.T) {
previous := defaultLogger
var output bytes.Buffer
defaultLogger = slog.New(NewConsoleHandler(&output, slog.LevelDebug))
t.Cleanup(func() { defaultLogger = previous })
mysqlDriverLogger{}.Print("packets.go:58 ", "unexpected EOF")
line := stripANSI(output.String())
if !regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} \[ERROR/MYSQL\]`).MatchString(line) {
t.Fatalf("unexpected console log format %q", line)
}
if !strings.Contains(line, "packets.go:58 unexpected EOF") {
t.Fatalf("expected MySQL driver error in log output %q", line)
}
}
func TestGormLoggerSkipsSuccessfulQueriesByDefault(t *testing.T) {
previous := defaultLogger
var output bytes.Buffer
defaultLogger = slog.New(NewConsoleHandler(&output, slog.LevelDebug))
t.Cleanup(func() { defaultLogger = previous })
logger := NewGormLogger()
if logger.LogLevel != gormlogger.Warn {
t.Fatalf("default GORM log level = %v, want %v", logger.LogLevel, gormlogger.Warn)
}
logger.Trace(t.Context(), time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
if output.Len() != 0 {
t.Fatalf("successful query should not be logged by default: %q", output.String())
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
package factory
import (
"log"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/item"
)
@@ -19,7 +19,7 @@ func RegisterMessageSender(constructor MessageSenderConstructor) {
panic("Message sender constructor returned nil")
}
if _, exists := senders[sender.GetName()]; exists {
log.Println("Message sender already registered: " + sender.GetName())
logger.InfoArgs("message-sender", "Message sender already registered: "+sender.GetName())
}
senders[sender.GetName()] = sender
@@ -54,7 +54,7 @@ func GetAllMessageSenderNames() []string {
func Initialize() {
for _, sender := range senders {
if err := sender.Init(); err != nil {
log.Printf("Failed to initialize message sender %s: %v", sender.GetName(), err)
logger.Errorf("message-sender", "Failed to initialize message sender %s: %v", sender.GetName(), err)
}
}
}
+7 -4
View File
@@ -8,11 +8,14 @@ import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func TestParseTemplateFormatsEventTimeAsUTCNanoseconds(t *testing.T) {
local := time.FixedZone("UTC+8", 8*60*60)
eventTime := time.Date(2026, 7, 17, 9, 30, 0, 123456789, local)
func TestParseTemplateFormatsEventTimeInLocalTimezone(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
t.Cleanup(func() { time.Local = originalLocal })
eventTime := time.Date(2026, 7, 17, 1, 30, 0, 123456789, time.UTC)
got := parseTemplate("{{time}}", models.EventMessage{Time: eventTime})
want := "2026-07-17T01:30:00.123456789Z"
want := "2026-07-17T09:30:00.123456789+08:00"
if got != want {
t.Fatalf("formatted event time = %q, want %q", got, want)
}
+5 -5
View File
@@ -3,7 +3,7 @@ package messageSender
import (
"encoding/json"
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"sync"
"time"
@@ -11,7 +11,7 @@ import (
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
@@ -51,14 +51,14 @@ func Initialize() {
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
logger.Errorf("message-sender", "Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
logger.Errorf("message-sender", "Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
@@ -170,7 +170,7 @@ func parseTemplate(messageTemplate string, event models.EventMessage) string {
replaceMap := map[string]string{
"{{event}}": event.Event,
"{{client}}": joinedClients,
"{{time}}": event.Time.UTC().Format(time.RFC3339Nano),
"{{time}}": event.Time.In(time.Local).Format(time.RFC3339Nano),
"{{message}}": event.Message,
"{{emoji}}": event.Emoji,
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/timeutil"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
+8 -8
View File
@@ -2,7 +2,7 @@ package notifier
import (
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"reflect"
"sync"
"time"
@@ -12,7 +12,7 @@ import (
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/messageSender"
)
@@ -31,7 +31,7 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
m.mu.Lock()
defer m.mu.Unlock()
corn.RemovePrefix("load-notification:")
scheduler.RemovePrefix("load-notification:")
m.tasks = make(map[int][]models.LoadNotification)
// 按Interval分组任务
@@ -45,7 +45,7 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
interval := interval
tasks := append([]models.LoadNotification(nil), tasks...)
m.tasks[interval] = tasks
if err := corn.AddFunc(fmt.Sprintf("load-notification:%d", interval), corn.Every(time.Duration(interval)*time.Minute), func() {
if err := scheduler.AddFunc(fmt.Sprintf("load-notification:%d", interval), scheduler.Every(time.Duration(interval)*time.Minute), func() {
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
@@ -140,7 +140,7 @@ func getMetricValue(record models.Record, metric string) float32 {
case "ram":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.RamTotal > 0 {
@@ -150,7 +150,7 @@ func getMetricValue(record models.Record, metric string) float32 {
case "swap":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.SwapTotal > 0 {
@@ -164,7 +164,7 @@ func getMetricValue(record models.Record, metric string) float32 {
case "disk":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.DiskTotal > 0 {
@@ -225,7 +225,7 @@ func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
func updateLastNotified(taskId uint, notifyTime time.Time) {
db := dbcore.GetDBInstance()
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime.UTC()).Error; err != nil {
log.Printf("Failed to update last_notified for task %d: %v", taskId, err)
logger.Errorf("notifier", "Failed to update last_notified for task %d: %v", taskId, err)
}
}
+8 -8
View File
@@ -2,7 +2,7 @@ package notifier
import (
"fmt"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"sync"
"time"
@@ -10,7 +10,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"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/internal/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -40,7 +40,7 @@ func getNotificationConfig(clientID string) (*models.OfflineNotification, bool)
notiConf := models.OfflineNotification{Client: clientID}
db := dbcore.GetDBInstance()
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&notiConf).Error; err != nil {
log.Printf("Failed to get or create offline notification config for client %s: %v", clientID, err)
logger.Errorf("notifier", "Failed to get or create offline notification config for client %s: %v", clientID, err)
return nil, false
}
@@ -96,7 +96,7 @@ func OfflineNotification(clientID string, endedConnectionID int64) {
// 若为零值,说明客户端已重连。
// 当前的 connectionID 是否还是我们触发离线时的那个ID。如果不是,说明客户端重连过,本次离线通知已失效。
if state.pendingOfflineSince.IsZero() || state.connectionID != expectedConnectionID {
log.Printf("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
logger.Infof("notifier", "%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
return
}
@@ -115,14 +115,14 @@ func OfflineNotification(clientID string, endedConnectionID int64) {
//Message: msg,
Emoji: "🔴",
}); err != nil {
log.Println("Failed to send offline notification:", err)
logger.ErrorArgs("notifier", "Failed to send offline notification:", err)
}
}(message)
// 更新数据库中的最后通知时间
db := dbcore.GetDBInstance()
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now.UTC()).Error; err != nil {
log.Printf("Failed to update last_notified for client %s: %v", clientID, err)
logger.Errorf("notifier", "Failed to update last_notified for client %s: %v", clientID, err)
}
}(now, endedConnectionID)
}
@@ -168,7 +168,7 @@ func OnlineNotification(clientID string, connectionID int64) {
// 规则3: 没断开后重连, 不通知
// 为了解决OfflineNotify中不是全程加锁
if state.isConnExist {
log.Printf("%s has connection exist: %d", clientID, connectionID)
logger.Infof("notifier", "%s has connection exist: %d", clientID, connectionID)
return
} else {
state.isConnExist = true
@@ -184,7 +184,7 @@ func OnlineNotification(clientID string, connectionID int64) {
//Message: msg,
Emoji: "🟢",
}); err != nil {
log.Println("Failed to send online notification:", err)
logger.ErrorArgs("notifier", "Failed to send online notification:", err)
}
}(message)
}
+3 -3
View File
@@ -2,14 +2,14 @@ package notifier
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
agent_runtime "github.com/komari-monitor/komari/web/agent"
cache "github.com/patrickmn/go-cache"
@@ -29,7 +29,7 @@ func CheckTraffic() {
}
cfg, err := config.GetAs[float64](config.TrafficLimitPercentageKey, 80.0)
if err != nil {
slog.Error("failed to get traffic limit percentage", "error", err)
logger.Error("notifier", "failed to get traffic limit percentage", "error", err)
}
if cfg <= 0 {
+14 -15
View File
@@ -2,46 +2,45 @@ package notifier
import (
"fmt"
"log"
"sort"
"strings"
"time"
logger "github.com/komari-monitor/komari/utils/log"
"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/internal/config"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/messageSender"
)
// InitTrafficReportSchedule 注册三个定时任务:日报、周报、月报
func InitTrafficReportSchedule() {
// 日报:每天凌晨 0 点
if err := corn.AddFunc("traffic-report-daily", "0 0 0 * * *", func() {
if err := scheduler.AddFunc("traffic-report-daily", "0 0 0 * * *", func() {
sendTrafficReport(true, false, false)
}); err != nil {
log.Println("Failed to register daily traffic report job:", err)
logger.ErrorArgs("notifier", "Failed to register daily traffic report job:", err)
}
// 周报:每周一凌晨 0 点 (dow=1)
if err := corn.AddFunc("traffic-report-weekly", "0 0 0 * * 1", func() {
if err := scheduler.AddFunc("traffic-report-weekly", "0 0 0 * * 1", func() {
sendTrafficReport(false, true, false)
}); err != nil {
log.Println("Failed to register weekly traffic report job:", err)
logger.ErrorArgs("notifier", "Failed to register weekly traffic report job:", err)
}
// 月报:每月 1 日凌晨 0 点
if err := corn.AddFunc("traffic-report-monthly", "0 0 0 1 * *", func() {
if err := scheduler.AddFunc("traffic-report-monthly", "0 0 0 1 * *", func() {
sendTrafficReport(false, false, true)
}); err != nil {
log.Println("Failed to register monthly traffic report job:", err)
logger.ErrorArgs("notifier", "Failed to register monthly traffic report job:", err)
}
log.Println("Traffic report schedules registered: daily, weekly, monthly")
}
// sendTrafficReport 汇聚所有启用了指定报告类型的服务器流量,合并成一条通知发送
@@ -86,7 +85,7 @@ func sendTrafficReport(daily, weekly, monthly bool) {
query = query.Where("monthly = ?", true)
}
if err := query.Find(&notifications).Error; err != nil {
log.Printf("Failed to query traffic report notifications (%s): %v", label, err)
logger.Errorf("notifier", "Failed to query traffic report notifications (%s): %v", label, err)
return
}
if len(notifications) == 0 {
@@ -100,7 +99,7 @@ func sendTrafficReport(daily, weekly, monthly bool) {
}
var clientList []models.Client
if err := db.Where("uuid IN ?", clientUUIDs).Find(&clientList).Error; err != nil {
log.Printf("Failed to query clients for traffic report (%s): %v", label, err)
logger.Errorf("notifier", "Failed to query clients for traffic report (%s): %v", label, err)
return
}
clientMap := make(map[string]models.Client, len(clientList))
@@ -119,7 +118,7 @@ func sendTrafficReport(daily, weekly, monthly bool) {
used, err := getClientTrafficInRange(n.Client, c.TrafficLimitType, start, end)
if err != nil {
log.Printf("Failed to compute traffic for client %s (%s): %v", n.Client, label, err)
logger.Errorf("notifier", "Failed to compute traffic for client %s (%s): %v", n.Client, label, err)
continue
}
@@ -149,7 +148,7 @@ func sendTrafficReport(daily, weekly, monthly bool) {
Emoji: emoji,
Message: message,
}); err != nil {
log.Printf("Failed to send %s traffic report: %v", label, err)
logger.Errorf("notifier", "Failed to send %s traffic report: %v", label, err)
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/internal/scheduler"
v2 "github.com/komari-monitor/komari/protocol/v2"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
@@ -27,7 +27,7 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
corn.RemovePrefix("ping:")
scheduler.RemovePrefix("ping:")
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
@@ -44,7 +44,7 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
interval := interval
tasks := append([]models.PingTask(nil), tasks...)
m.tasks[interval] = tasks
if err := corn.AddContextFunc(fmt.Sprintf("ping:%d", interval), corn.Every(time.Duration(interval)*time.Second), false, func(ctx context.Context) {
if err := scheduler.AddContextFunc(fmt.Sprintf("ping:%d", interval), scheduler.Every(time.Duration(interval)*time.Second), false, func(ctx context.Context) {
for _, task := range tasks {
go executePingTask(ctx, task)
}
-4
View File
@@ -30,10 +30,6 @@ func GenerateRandomString(length int) string {
return string(out)
}
func GeneratePassword() string {
return GenerateRandomString(12)
}
func GenerateToken() string {
return GenerateRandomString(22)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/rpc"
"gorm.io/gorm"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/web/security"
)
+44 -3
View File
@@ -16,11 +16,18 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/public"
)
const (
maxThemeArchiveFiles = 10000
maxThemeFileSize = 128 << 20
maxThemeExtractedSize = 512 << 20
maxThemeManifestSize = 1 << 20
)
// UploadTheme 上传主题
func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
@@ -174,6 +181,10 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
}
defer r.Close()
if err := validateThemeArchive(r.File); err != nil {
return themeInfo, err
}
// 查找komari-theme.json文件
var themeConfigFile *zip.File
for _, f := range r.File {
@@ -194,10 +205,13 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
}
defer rc.Close()
configData, err := io.ReadAll(rc)
configData, err := io.ReadAll(io.LimitReader(rc, maxThemeManifestSize+1))
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if len(configData) > maxThemeManifestSize {
return themeInfo, fmt.Errorf("主题配置文件超过 %d 字节限制", maxThemeManifestSize)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
@@ -274,6 +288,26 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
return themeInfo, nil
}
func validateThemeArchive(files []*zip.File) error {
if len(files) > maxThemeArchiveFiles {
return fmt.Errorf("主题压缩包文件数量超过 %d 个限制", maxThemeArchiveFiles)
}
var total uint64
for _, file := range files {
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxThemeFileSize {
return fmt.Errorf("主题文件 %s 超过 %d 字节限制", file.Name, maxThemeFileSize)
}
total += file.UncompressedSize64
if total > maxThemeExtractedSize {
return fmt.Errorf("主题解压后总大小超过 %d 字节限制", maxThemeExtractedSize)
}
}
return nil
}
// loadThemeConfig 加载主题配置
func loadThemeConfig(configPath string) (models.Theme, error) {
var themeInfo models.Theme
@@ -657,6 +691,10 @@ func peekThemeFromZip(zipPath string) (models.Theme, error) {
}
defer r.Close()
if err := validateThemeArchive(r.File); err != nil {
return themeInfo, err
}
var themeConfigFile *zip.File
for _, f := range r.File {
if f.Name == "komari-theme.json" {
@@ -675,10 +713,13 @@ func peekThemeFromZip(zipPath string) (models.Theme, error) {
}
defer rc.Close()
configData, err := io.ReadAll(rc)
configData, err := io.ReadAll(io.LimitReader(rc, maxThemeManifestSize+1))
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if len(configData) > maxThemeManifestSize {
return themeInfo, fmt.Errorf("主题配置文件超过 %d 字节限制", maxThemeManifestSize)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
+515
View File
@@ -0,0 +1,515 @@
package admin
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/web/api"
)
const (
defaultThemeMarketURL = "https://raw.githubusercontent.com/komari-monitor/theme-market/main/v1.json"
marketCatalogMaxSize = 2 << 20
marketThemeMaxSize = 100 << 20
marketCacheTTL = 10 * time.Minute
)
type ThemeMarketSource struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
}
type ThemeMarketTheme struct {
Name any `json:"name"`
Short string `json:"short"`
Description any `json:"description"`
Version string `json:"version"`
Author any `json:"author"`
URL string `json:"url"`
Preview string `json:"preview"`
Download string `json:"download"`
SHA256 string `json:"sha256"`
Installable bool `json:"installable"`
SourceID string `json:"source_id,omitempty"`
SourceName string `json:"source_name,omitempty"`
}
type themeMarketCatalog struct {
Schema int `json:"schema"`
Themes []ThemeMarketTheme `json:"themes"`
}
type themeMarketSourceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
type cachedThemeMarketCatalog struct {
Themes []ThemeMarketTheme
ExpiresAt time.Time
}
var themeMarketCache = struct {
sync.RWMutex
items map[string]cachedThemeMarketCatalog
}{items: make(map[string]cachedThemeMarketCatalog)}
func defaultThemeMarketSources() []ThemeMarketSource {
return []ThemeMarketSource{{
ID: "official",
Name: "Komari Official",
URL: defaultThemeMarketURL,
Enabled: true,
}}
}
func getThemeMarketSources() ([]ThemeMarketSource, error) {
return config.GetAs[[]ThemeMarketSource](config.ThemeMarketSourcesKey, defaultThemeMarketSources())
}
func saveThemeMarketSources(sources []ThemeMarketSource) error {
return config.Set(config.ThemeMarketSourcesKey, sources)
}
func normalizeThemeMarketSource(source ThemeMarketSource) (ThemeMarketSource, error) {
source.ID = strings.TrimSpace(source.ID)
source.Name = strings.TrimSpace(source.Name)
source.URL = strings.TrimSpace(source.URL)
if source.Name == "" {
return source, errors.New("source name is required")
}
parsed, err := url.Parse(source.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil {
return source, errors.New("source URL must be a valid HTTP or HTTPS URL")
}
return source, nil
}
func newThemeMarketSourceID() (string, error) {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func ListThemeMarketSources(c *gin.Context) {
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
api.RespondSuccess(c, sources)
}
func CreateThemeMarketSource(c *gin.Context) {
var source ThemeMarketSource
if err := c.ShouldBindJSON(&source); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
var err error
source, err = normalizeThemeMarketSource(source)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
source.ID, err = newThemeMarketSourceID()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create source ID")
return
}
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
for _, existing := range sources {
if existing.URL == source.URL {
api.RespondError(c, http.StatusConflict, "A source with this URL already exists")
return
}
}
sources = append(sources, source)
if err := saveThemeMarketSources(sources); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save theme market source: "+err.Error())
return
}
api.RespondSuccessMessage(c, "Theme market source created", source)
}
func UpdateThemeMarketSource(c *gin.Context) {
var update ThemeMarketSource
if err := c.ShouldBindJSON(&update); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
update.ID = c.Param("id")
var err error
update, err = normalizeThemeMarketSource(update)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
found := false
oldURL := ""
for i := range sources {
if sources[i].ID == update.ID {
oldURL = sources[i].URL
sources[i] = update
found = true
continue
}
if sources[i].URL == update.URL {
api.RespondError(c, http.StatusConflict, "A source with this URL already exists")
return
}
}
if !found {
api.RespondError(c, http.StatusNotFound, "Theme market source not found")
return
}
if err := saveThemeMarketSources(sources); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save theme market source: "+err.Error())
return
}
invalidateThemeMarketCache(oldURL)
if oldURL != update.URL {
invalidateThemeMarketCache(update.URL)
}
api.RespondSuccessMessage(c, "Theme market source updated", update)
}
func DeleteThemeMarketSource(c *gin.Context) {
id := c.Param("id")
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
next := make([]ThemeMarketSource, 0, len(sources))
var deleted *ThemeMarketSource
for i := range sources {
if sources[i].ID == id {
copy := sources[i]
deleted = &copy
continue
}
next = append(next, sources[i])
}
if deleted == nil {
api.RespondError(c, http.StatusNotFound, "Theme market source not found")
return
}
if err := saveThemeMarketSources(next); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete theme market source: "+err.Error())
return
}
invalidateThemeMarketCache(deleted.URL)
api.RespondSuccessMessage(c, "Theme market source deleted", nil)
}
func ListThemeMarketCatalog(c *gin.Context) {
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
force := c.Query("refresh") == "true"
themes := make([]ThemeMarketTheme, 0)
statuses := make([]themeMarketSourceStatus, len(sources))
results := make([][]ThemeMarketTheme, len(sources))
var wg sync.WaitGroup
for i, source := range sources {
statuses[i] = themeMarketSourceStatus{ID: source.ID, Name: source.Name, URL: source.URL}
if !source.Enabled {
continue
}
wg.Add(1)
go func(index int, item ThemeMarketSource) {
defer wg.Done()
items, fetchErr := fetchThemeMarketCatalog(item, force)
if fetchErr != nil {
statuses[index].Error = fetchErr.Error()
return
}
results[index] = items
statuses[index].Count = len(items)
}(i, source)
}
wg.Wait()
for _, items := range results {
themes = append(themes, items...)
}
api.RespondSuccess(c, gin.H{"themes": themes, "sources": statuses})
}
func InstallThemeFromMarket(c *gin.Context) {
var req struct {
SourceID string `json:"source_id" binding:"required"`
Short string `json:"short" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
sources, err := getThemeMarketSources()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to load theme market sources: "+err.Error())
return
}
var source *ThemeMarketSource
for i := range sources {
if sources[i].ID == req.SourceID && sources[i].Enabled {
source = &sources[i]
break
}
}
if source == nil {
api.RespondError(c, http.StatusNotFound, "Theme market source not found or disabled")
return
}
items, err := fetchThemeMarketCatalog(*source, true)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Failed to load theme market source: "+err.Error())
return
}
var selected *ThemeMarketTheme
for i := range items {
if items[i].Short == req.Short {
selected = &items[i]
break
}
}
if selected == nil {
api.RespondError(c, http.StatusNotFound, "Theme not found in source")
return
}
if !selected.Installable {
api.RespondError(c, http.StatusBadRequest, "This theme does not provide an installable package")
return
}
data, err := downloadThemeMarketURL(selected.Download, marketThemeMaxSize)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Failed to download theme: "+err.Error())
return
}
digest := sha256.Sum256(data)
if !strings.EqualFold(hex.EncodeToString(digest[:]), selected.SHA256) {
api.RespondError(c, http.StatusBadRequest, "Theme SHA-256 checksum does not match the market catalog")
return
}
tempFile, err := os.CreateTemp("", "komari-market-theme-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create temporary theme file")
return
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
if _, err := tempFile.Write(data); err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, "Failed to save temporary theme file")
return
}
if err := tempFile.Close(); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save temporary theme file")
return
}
manifest, err := peekThemeFromZip(tempPath)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if manifest.Short != selected.Short || manifest.Version != selected.Version {
api.RespondError(c, http.StatusBadRequest, "Theme manifest does not match the market catalog")
return
}
installed, err := extractAndValidateTheme(tempPath)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
api.RespondSuccessMessage(c, "Theme installed from market", installed)
}
func fetchThemeMarketCatalog(source ThemeMarketSource, force bool) ([]ThemeMarketTheme, error) {
if !force {
themeMarketCache.RLock()
cached, ok := themeMarketCache.items[source.URL]
themeMarketCache.RUnlock()
if ok && time.Now().Before(cached.ExpiresAt) {
return append([]ThemeMarketTheme(nil), cached.Themes...), nil
}
}
data, err := downloadThemeMarketURL(source.URL, marketCatalogMaxSize)
if err != nil {
return nil, err
}
themes, err := parseThemeMarketCatalog(data)
if err != nil {
return nil, err
}
for i := range themes {
if err := validateThemeMarketTheme(themes[i]); err != nil {
return nil, fmt.Errorf("theme %q: %w", themes[i].Short, err)
}
themes[i].SHA256 = strings.TrimPrefix(strings.ToLower(themes[i].SHA256), "sha256:")
themes[i].Installable = themes[i].Download != "" && themes[i].SHA256 != ""
themes[i].SourceID = source.ID
themes[i].SourceName = source.Name
}
themeMarketCache.Lock()
themeMarketCache.items[source.URL] = cachedThemeMarketCatalog{Themes: themes, ExpiresAt: time.Now().Add(marketCacheTTL)}
themeMarketCache.Unlock()
return append([]ThemeMarketTheme(nil), themes...), nil
}
func parseThemeMarketCatalog(data []byte) ([]ThemeMarketTheme, error) {
var catalog themeMarketCatalog
if err := json.Unmarshal(data, &catalog); err == nil && catalog.Themes != nil {
return catalog.Themes, nil
}
var themes []ThemeMarketTheme
if err := json.Unmarshal(data, &themes); err == nil && themes != nil {
return themes, nil
}
var theme ThemeMarketTheme
if err := json.Unmarshal(data, &theme); err != nil {
return nil, fmt.Errorf("invalid market catalog JSON: %w", err)
}
if theme.Short == "" {
return nil, errors.New("market catalog must contain a themes array or a theme object")
}
return []ThemeMarketTheme{theme}, nil
}
func validateThemeMarketTheme(theme ThemeMarketTheme) error {
if !isThemeMarketText(theme.Name) || theme.Short == "" || theme.Version == "" || !isThemeMarketText(theme.Author) {
return errors.New("name, short, version and author are required")
}
if !isValidThemeShort(theme.Short) {
return errors.New("short contains invalid characters")
}
if (theme.Download == "") != (theme.SHA256 == "") {
return errors.New("download and sha256 must be provided together")
}
urls := []struct {
field string
value string
}{{"url", theme.URL}, {"preview", theme.Preview}, {"download", theme.Download}}
for _, item := range urls {
field, value := item.field, item.value
if value == "" && (field == "preview" || field == "download") {
continue
}
if err := validateThemeMarketURLSyntax(value); err != nil {
return fmt.Errorf("%s: %w", field, err)
}
}
if theme.SHA256 == "" {
return nil
}
sha := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(theme.SHA256)), "sha256:")
if len(sha) != sha256.Size*2 {
return errors.New("sha256 must contain 64 hexadecimal characters")
}
if _, err := hex.DecodeString(sha); err != nil {
return errors.New("sha256 must contain 64 hexadecimal characters")
}
return nil
}
func isThemeMarketText(value any) bool {
switch text := value.(type) {
case string:
return strings.TrimSpace(text) != ""
case map[string]any:
for _, item := range text {
if itemText, ok := item.(string); ok && strings.TrimSpace(itemText) != "" {
return true
}
}
}
return false
}
func validateThemeMarketURLSyntax(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil {
return errors.New("must be a valid HTTP or HTTPS URL")
}
return nil
}
func downloadThemeMarketURL(rawURL string, maxSize int64) ([]byte, error) {
validate := func(candidate string) error {
parsed, err := url.Parse(candidate)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil {
return errors.New("only public HTTP and HTTPS URLs are allowed")
}
if isPrivateIP(parsed.Hostname()) {
return errors.New("requests to private or internal addresses are not allowed")
}
return nil
}
if err := validate(rawURL); err != nil {
return nil, err
}
client := &http.Client{
Timeout: 45 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("too many redirects")
}
return validate(req.URL.String())
},
}
resp, err := client.Get(rawURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxSize {
return nil, fmt.Errorf("response exceeds the %d byte limit", maxSize)
}
if len(data) == 0 {
return nil, errors.New("empty response")
}
return data, nil
}
func invalidateThemeMarketCache(rawURL string) {
themeMarketCache.Lock()
delete(themeMarketCache.items, rawURL)
themeMarketCache.Unlock()
}
+68
View File
@@ -0,0 +1,68 @@
package admin
import (
"archive/zip"
"testing"
)
func TestParseThemeMarketCatalogShapes(t *testing.T) {
theme := `{"name":"Test","short":"Test","version":"1.0.0","author":"Author","download":"https://example.com/theme.zip","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`
tests := []string{
theme,
`[` + theme + `]`,
`{"schema":1,"themes":[` + theme + `]}`,
}
for _, input := range tests {
themes, err := parseThemeMarketCatalog([]byte(input))
if err != nil {
t.Fatalf("parseThemeMarketCatalog() error = %v", err)
}
if len(themes) != 1 || themes[0].Short != "Test" {
t.Fatalf("parseThemeMarketCatalog() = %#v", themes)
}
}
}
func TestValidateThemeArchiveLimits(t *testing.T) {
files := make([]*zip.File, maxThemeArchiveFiles+1)
for i := range files {
files[i] = &zip.File{}
}
if err := validateThemeArchive(files); err == nil {
t.Fatal("validateThemeArchive() accepted too many files")
}
large := &zip.File{FileHeader: zip.FileHeader{UncompressedSize64: maxThemeFileSize + 1}}
if err := validateThemeArchive([]*zip.File{large}); err == nil {
t.Fatal("validateThemeArchive() accepted an oversized file")
}
}
func TestValidateThemeMarketThemeChecksum(t *testing.T) {
valid := ThemeMarketTheme{
Name: "Test", Short: "Test", Version: "1.0.0", Author: "Author",
URL: "https://example.com/theme",
Download: "https://example.com/theme.zip",
SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}
if err := validateThemeMarketTheme(valid); err != nil {
t.Fatalf("validateThemeMarketTheme() error = %v", err)
}
valid.SHA256 = "xxxxxx"
if err := validateThemeMarketTheme(valid); err == nil {
t.Fatal("validateThemeMarketTheme() accepted an invalid checksum")
}
}
func TestThemeMarketI18nTextAndSourceOnlyEntry(t *testing.T) {
theme := ThemeMarketTheme{
Name: map[string]any{"zh-CN": "测试", "en": "Test"},
Short: "source-only",
Version: "source",
Author: map[string]any{"en": "Author"},
URL: "https://example.com/theme",
}
if err := validateThemeMarketTheme(theme); err != nil {
t.Fatalf("validateThemeMarketTheme() error = %v", err)
}
}
+7 -94
View File
@@ -1,124 +1,37 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/backup"
)
// 只有一个备份恢复操作在进行
var restoreMutex sync.Mutex
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
// UploadBackup receives a backup and applies it after the service restarts.
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("get uploaded backup: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
if err := backup.SaveUploadedBackup(file, header.Filename); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
// 将上传的文件内容复制到临时文件
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
// 基础校验:检查是否包含标记文件
if zr, err := zip.OpenReader(tempFilePath); err == nil {
hasMarkup := false
for _, f := range zr.File {
if f.Name == "komari-backup-markup" {
hasMarkup = true
break
}
}
zr.Close()
if !hasMarkup {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
// 将校验通过的临时文件移动到固定路径 ./data/backup.zip
finalPath := filepath.Join(".", "data", "backup.zip")
// 如存在旧文件,先删除
_ = os.Remove(finalPath)
if err := os.Rename(tempFilePath, finalPath); err != nil {
// fallback:拷贝
in, err2 := os.Open(tempFilePath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
return
}
defer in.Close()
out, err2 := os.Create(finalPath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
return
}
if _, err2 = io.Copy(out, in); err2 != nil {
out.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
return
}
out.Close()
}
// 返回:已保存备份,重启后将自动恢复
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
"path": "./data/backup.zip",
})
go func() {
log.Println("Backup uploaded, restarting service in 2 seconds to apply on startup...")
logger.InfoArgs("admin-api", "Backup uploaded, restarting service in 2 seconds to apply on startup...")
time.Sleep(2 * time.Second)
os.Exit(0)
}()
+1 -1
View File
@@ -3,7 +3,7 @@ package client
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/web/api"
)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"sync"
"time"
"log"
logger "github.com/komari-monitor/komari/utils/log"
"net/http"
"github.com/gin-gonic/gin"
@@ -99,7 +99,7 @@ func postPresenceExpired(uuid string, connID int64, gen uint64) {
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Println("Failed to read request body:", err)
logger.ErrorArgs("client-api", "Failed to read request body:", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
@@ -156,7 +156,7 @@ func WebSocketReport(c *gin.Context) {
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
logger.ErrorArgs("client-api", "Error reading message:", err)
return
}
@@ -188,13 +188,13 @@ func WebSocketReport(c *gin.Context) {
// 接受新连接,并处理旧连接
if oldConn, exists := agent_runtime.GetConnectedClients()[uuid]; exists {
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
logger.Infof("client-api", "Client %s is reconnecting. Closing the old connection.", uuid)
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
go oldConn.Close()
}
agent_runtime.SetConnectedClients(uuid, conn)
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
logger.Infof("client-api", "Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
agent_runtime.DeleteClientConditionally(uuid, conn)
@@ -210,7 +210,7 @@ func WebSocketReport(c *gin.Context) {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Client %s connection error: %v", uuid, err)
logger.Errorf("client-api", "Client %s connection error: %v", uuid, err)
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
@@ -258,7 +258,7 @@ func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
conn.WriteJSON(gin.H{"status": "error", "error": "Failed to save ping result"})
}
default:
log.Printf("Unknown message type: %s", msgType.Type)
logger.Warnf("client-api", "Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
+4 -4
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"compress/gzip"
"encoding/json"
logger "github.com/komari-monitor/komari/utils/log"
"io"
"log"
"net/http"
"strings"
"time"
@@ -156,7 +156,7 @@ func WebSocketV2RPC(c *gin.Context) {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Client %s v2 connection error: %v", uuid, err)
logger.Errorf("client-api", "Client %s v2 connection error: %v", uuid, err)
}
return
}
@@ -169,7 +169,7 @@ func WebSocketV2RPC(c *gin.Context) {
resp := handleV2RPC(uuid, req, false)
if req.ID != nil {
if err := conn.WriteJSON(resp); err != nil {
log.Printf("failed to write v2 rpc response: %v", err)
logger.Errorf("client-api", "failed to write v2 rpc response: %v", err)
return
}
}
@@ -186,7 +186,7 @@ func pushQueuedV2Events(conn *connection.SafeConn, uuid string) bool {
payload := v2.Request{JSONRPC: v2.Version, Method: event.Method, Params: event.Params}
if err := conn.WriteJSON(payload); err != nil {
agent_runtime.AckV2Events(uuid, ackIDs)
log.Printf("failed to push queued v2 event %s to client %s: %v", event.ID, uuid, err)
logger.Errorf("client-api", "failed to push queued v2 event %s to client %s: %v", event.ID, uuid, err)
return false
}
ackIDs = append(ackIDs, event.ID)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gin-gonic/gin"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils/geoip"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/web/api"
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
conf "github.com/komari-monitor/komari/pkg/config"
conf "github.com/komari-monitor/komari/internal/config"
jsonRpc "github.com/komari-monitor/komari/web/rpc/jsonrpc"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/web/oauth"
)
+2 -2
View File
@@ -1,7 +1,7 @@
package terminal
import (
"log"
logger "github.com/komari-monitor/komari/utils/log"
"net/http"
"time"
@@ -46,7 +46,7 @@ func RequestTerminal(c *gin.Context) {
TerminalSessions[id] = session
TerminalSessionsMutex.Unlock()
conn.SetCloseHandler(func(code int, text string) error {
log.Println("Terminal connection closed:", code, text)
logger.InfoArgs("terminal", "Terminal connection closed:", code, text)
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more