mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-12 09:24:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab9c6724e | ||
|
|
6efc72ce31 | ||
|
|
1f6bd1391a | ||
|
|
5b93181d94 | ||
|
|
f07bd749c4 | ||
|
|
887381ad4f | ||
|
|
11617b46b3 | ||
|
|
5a8488926c | ||
|
|
db2ed8e424 | ||
|
|
312834f921 | ||
|
|
9d149ab660 | ||
|
|
9d7e24ab78 | ||
|
|
99c684e75e | ||
|
|
a402cc6189 | ||
|
|
0bac110d8a | ||
|
|
7a955467fd | ||
|
|
eb7ecc6fe4 | ||
|
|
f59e0e813c | ||
|
|
b4d3977871 | ||
|
|
b55436842f | ||
|
|
cdfd5bd13f | ||
|
|
5a66f28e81 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 统一收尾:设置终态状态、结束时间与错误信息。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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` フォルダに保存されます。
|
||||
|
||||
@@ -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
@@ -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` 文件夹中。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+102
-10
@@ -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"
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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, ×tamp,
|
||||
)
|
||||
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 的聚合会优先下推。
|
||||
|
||||
@@ -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 设置。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)...)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬iConf).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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬ifications).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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 = ©
|
||||
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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user