feat(v2.0.0): tsdb (#1162)

* feat: tsdb

* fix(ci): remove --parseGoList=false from swag init to fix dependency resolution

* fix(ci): fix swag init directory and temporary remove s390x support due to cgo issues

* fix(ci): fix swag init output directory to cmd/dashboard/docs

* fix(ci): set GOTOOLCHAIN=auto for gosec

* feat: add system storage maintenance for SQLite and TSDB

* shit

* feat: add s390x support and improve service monitoring

* ci: upgrade goreleaser-cross image to v1.25

* ci: add libzstd-dev:s390x for cross-compilation

* ci: build libzstd for s390x from source

* ci: add libzstd_linux_s390x.go for gozstd linking

* ci: use vendor mode for s390x gozstd build

* ci: clone zstd source for s390x build

* refactor(tsdb): rename MaxDiskUsageGB to MinFreeDiskSpaceGB and optimize queries

- Rename config to accurately reflect VictoriaMetrics behavior: minimum free disk space threshold
- Add QueryServiceHistoryByServerID for batch query optimization
- Fix hasStatus to avoid false status counting when only delay data exists
- Fix service aggregation boundary: use successCount*2 >= count
- Fix serviceID parsing with strconv.ParseUint error handling
- Add TagFiltersCacheSize for better query performance

* feat(api): add server metrics endpoint and simplify service history response

- Add /server/:id/metrics API for querying TSDB server metrics
- Simplify getServiceHistory by removing redundant data conversion
- Change AvgDelay type from float32 to float64
- Remove generated swagger docs (to be regenerated)
- Update TSDB query, writer and tests

* chore: 临时禁用不支持前端

* ci: cache zstd build for s390x to speed up CI

* fix(tsdb): fix race conditions, data correctness and optimize performance

- Fix TOCTOU race between IsClosed() and write/query by holding RLock
- Fix delay=0 excluded from stats by using hasDelay flag instead of value > 0
- Fix fmt.Sscanf -> strconv.ParseUint for server_id parsing with error logging
- Fix buffer unbounded growth by flushing inside lock when over maxSize
- Split makeMetricRow into makeServerMetricRow/makeServiceMetricRow
- Extract InitGlobalSettings() from Open() for VictoriaMetrics globals
- Remove redundant instance/GetInstance/SetInstance singleton
- Add error logging for silently skipped block decode errors
- Optimize WriteBatch* to build all rows in single write call
- Optimize downsample to use linear scan instead of map for sorted data
- Optimize query slice reuse across block iterations

* 服务添加DisplayIndex (#1166)

* 服务添加DisplayIndex

* 根据ai建议修改

---------

Co-authored-by: huYang <306061454@qq.com>

* fix(tsdb): restore SQLite fallback and monthly status reload on restart

- Restore ServiceHistory model and SQLite write fallback when TSDB is disabled
- Reload monthlyStatus (30-day) and serviceStatusToday from TSDB/SQLite on startup
- Add SQLite fallback query for /service/:id/history and /server/:id/service
- Remove breaking GET /service/:id endpoint, keep /service/:id/history only
- Add QueryServiceDailyStats to TSDB for per-day aggregation
- Add tests for monthly status and today stats loading from both TSDB and SQLite
- Migrate ServiceHistory table only when TSDB is disabled

* ci: exclude false-positive gosec rules G117, G703, G704

* feat(api): expose tsdb_enabled in setting response

* ci: restore G115 exclusion accidentally dropped in previous commit

* fix: update version numbers for OfficialAdmin and Official templates

* chore: upgrade frontend

* chore: upgrade frontend

---------

Co-authored-by: 胡说丷刂 <34758853+laosan-xx@users.noreply.github.com>
Co-authored-by: huYang <306061454@qq.com>
This commit is contained in:
奶爸
2026-02-15 13:13:33 +08:00
committed by GitHub
parent 4c4758207d
commit e61772e858
28 changed files with 3054 additions and 221 deletions
+44 -5
View File
@@ -15,17 +15,18 @@ jobs:
goos: [linux, windows] goos: [linux, windows]
goarch: [amd64] goarch: [amd64]
include: include:
- goos: linux
goarch: s390x
- goos: linux - goos: linux
goarch: arm64 goarch: arm64
- goos: linux
goarch: s390x
name: Build artifacts name: Build artifacts
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: goreleaser/goreleaser-cross:v1.24 image: goreleaser/goreleaser-cross:v1.25
steps: steps:
- run: | - name: Install dependencies
run: |
apt update && apt install unzip curl -y apt update && apt install unzip curl -y
wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq
chmod +x /usr/bin/yq chmod +x /usr/bin/yq
@@ -53,7 +54,45 @@ jobs:
- name: generate swagger docs - name: generate swagger docs
run: | run: |
go install github.com/swaggo/swag/cmd/swag@latest go install github.com/swaggo/swag/cmd/swag@latest
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
go mod tidy
- name: Cache zstd for s390x
if: matrix.goarch == 's390x'
id: cache-zstd
uses: actions/cache@v4
with:
path: /tmp/zstd-s390x
key: zstd-s390x-v1.5.7
- name: Build zstd for s390x
if: matrix.goarch == 's390x'
run: |
# gozstd doesn't ship prebuilt libzstd for s390x, build it manually
go mod vendor
if [ -f /tmp/zstd-s390x/libzstd.a ]; then
echo "Using cached libzstd.a"
else
# Clone zstd source and build for s390x
ZSTD_VERSION=v1.5.7
git clone --depth 1 --branch ${ZSTD_VERSION} https://github.com/facebook/zstd /tmp/zstd
cd /tmp/zstd/lib
CC=s390x-linux-gnu-gcc \
CXX=s390x-linux-gnu-g++ \
AR=s390x-linux-gnu-ar \
ZSTD_LEGACY_SUPPORT=0 \
make clean libzstd.a
mkdir -p /tmp/zstd-s390x
cp libzstd.a /tmp/zstd-s390x/libzstd.a
fi
# Copy to vendor directory
GOZSTD_VENDOR=${GITHUB_WORKSPACE}/vendor/github.com/valyala/gozstd
cp /tmp/zstd-s390x/libzstd.a ${GOZSTD_VENDOR}/libzstd_linux_s390x.a
# Create the Go file to link the library
printf '//go:build linux && s390x\n\npackage gozstd\n\n/*\n#cgo LDFLAGS: ${SRCDIR}/libzstd_linux_s390x.a\n*/\nimport "C"\n' > ${GOZSTD_VENDOR}/libzstd_linux_s390x.go
- name: Build with tag - name: Build with tag
if: contains(github.ref, 'refs/tags/') if: contains(github.ref, 'refs/tags/')
+4 -2
View File
@@ -34,7 +34,7 @@ jobs:
go install github.com/swaggo/swag/cmd/swag@latest go install github.com/swaggo/swag/cmd/swag@latest
touch ./cmd/dashboard/user-dist/a touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a touch ./cmd/dashboard/admin-dist/a
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Unit test - name: Unit test
run: | run: |
@@ -46,5 +46,7 @@ jobs:
- name: Run Gosec Security Scanner - name: Run Gosec Security Scanner
if: runner.os == 'Linux' if: runner.os == 'Linux'
uses: securego/gosec@master uses: securego/gosec@master
env:
GOTOOLCHAIN: auto
with: with:
args: --exclude=G104,G402,G115,G203 ./... args: --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
+1
View File
@@ -52,6 +52,7 @@ builds:
flags: flags:
- -trimpath - -trimpath
- -buildvcs=false - -buildvcs=false
- -mod=vendor
tags: tags:
- go_json - go_json
goos: goos:
+4 -1
View File
@@ -73,8 +73,10 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
optionalAuth.GET("/server-group", commonHandler(listServerGroup)) optionalAuth.GET("/server-group", commonHandler(listServerGroup))
optionalAuth.GET("/service", commonHandler(showService)) optionalAuth.GET("/service", commonHandler(showService))
optionalAuth.GET("/service/:id", commonHandler(listServiceHistory))
optionalAuth.GET("/service/server", commonHandler(listServerWithServices)) optionalAuth.GET("/service/server", commonHandler(listServerWithServices))
optionalAuth.GET("/service/:id/history", commonHandler(getServiceHistory))
optionalAuth.GET("/server/:id/service", commonHandler(listServerServices))
optionalAuth.GET("/server/:id/metrics", commonHandler(getServerMetrics))
auth := api.Group("", authMw) auth := api.Group("", authMw)
@@ -150,6 +152,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
auth.POST("/online-user/batch-block", adminHandler(batchBlockOnlineUser)) auth.POST("/online-user/batch-block", adminHandler(batchBlockOnlineUser))
auth.PATCH("/setting", adminHandler(updateConfig)) auth.PATCH("/setting", adminHandler(updateConfig))
auth.POST("/maintenance", adminHandler(runMaintenance))
r.NoRoute(fallbackToFrontend(frontendDist)) r.NoRoute(fallbackToFrontend(frontendDist))
} }
+87
View File
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/tsdb"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton" "github.com/nezhahq/nezha/service/singleton"
) )
@@ -366,3 +367,89 @@ func batchMoveServer(c *gin.Context) (any, error) {
return nil, nil return nil, nil
} }
var serverMetricMap = map[string]tsdb.MetricType{
"cpu": tsdb.MetricServerCPU,
"memory": tsdb.MetricServerMemory,
"swap": tsdb.MetricServerSwap,
"disk": tsdb.MetricServerDisk,
"net_in_speed": tsdb.MetricServerNetInSpeed,
"net_out_speed": tsdb.MetricServerNetOutSpeed,
"net_in_transfer": tsdb.MetricServerNetInTransfer,
"net_out_transfer": tsdb.MetricServerNetOutTransfer,
"load1": tsdb.MetricServerLoad1,
"load5": tsdb.MetricServerLoad5,
"load15": tsdb.MetricServerLoad15,
"tcp_conn": tsdb.MetricServerTCPConn,
"udp_conn": tsdb.MetricServerUDPConn,
"process_count": tsdb.MetricServerProcessCount,
"temperature": tsdb.MetricServerTemperature,
"uptime": tsdb.MetricServerUptime,
"gpu": tsdb.MetricServerGPU,
}
// Get server metrics history
// @Summary Get server metrics history
// @Security BearerAuth
// @Schemes
// @Description Get server metrics history for a specific server
// @Tags common
// @param id path uint true "Server ID"
// @param metric query string true "Metric name: cpu, memory, swap, disk, net_in_speed, net_out_speed, net_in_transfer, net_out_transfer, load1, load5, load15, tcp_conn, udp_conn, process_count, temperature, uptime, gpu"
// @param period query string false "Time period: 1d, 7d, 30d (default: 1d)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServerMetricsResponse]
// @Router /server/{id}/metrics [get]
func getServerMetrics(c *gin.Context) (*model.ServerMetricsResponse, error) {
idStr := c.Param("id")
serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
server, ok := singleton.ServerShared.Get(serverID)
if !ok {
return nil, singleton.Localizer.ErrorT("server not found")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
if server.HideForGuest && !isMember {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
metricName := c.Query("metric")
metricType, ok := serverMetricMap[metricName]
if !ok {
return nil, singleton.Localizer.ErrorT("invalid metric name")
}
periodStr := c.DefaultQuery("period", "1d")
period, err := tsdb.ParseQueryPeriod(periodStr)
if err != nil {
return nil, err
}
if !isMember && period != tsdb.Period1Day {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
response := &model.ServerMetricsResponse{
ServerID: serverID,
ServerName: server.Name,
Metric: metricName,
DataPoints: make([]model.ServerMetricsDataPoint, 0),
}
if !singleton.TSDBEnabled() {
return response, nil
}
points, err := singleton.TSDBShared.QueryServerMetrics(serverID, metricType, period)
if err != nil {
return nil, err
}
response.DataPoints = points
return response, nil
}
+272 -74
View File
@@ -9,11 +9,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/pkg/tsdb"
"github.com/nezhahq/nezha/service/singleton" "github.com/nezhahq/nezha/service/singleton"
"gorm.io/gorm"
) )
// Show service // Show service
@@ -55,7 +55,7 @@ func showService(c *gin.Context) (*model.ServiceResponse, error) {
// @Param id query uint false "Resource ID" // @Param id query uint false "Resource ID"
// @Produce json // @Produce json
// @Success 200 {object} model.CommonResponse[[]model.Service] // @Success 200 {object} model.CommonResponse[[]model.Service]
// @Router /service [get] // @Router /service/list [get]
func listService(c *gin.Context) ([]*model.Service, error) { func listService(c *gin.Context) ([]*model.Service, error) {
var ss []*model.Service var ss []*model.Service
ssl := singleton.ServiceSentinelShared.GetSortedList() ssl := singleton.ServiceSentinelShared.GetSortedList()
@@ -66,96 +66,321 @@ func listService(c *gin.Context) ([]*model.Service, error) {
return ss, nil return ss, nil
} }
// List service histories by server id // Get service history
// @Summary Get service history by service ID
// @Security BearerAuth
// @Schemes
// @Description Get service monitoring history for a specific service
// @Tags common
// @param id path uint true "Service ID"
// @param period query string false "Time period: 1d, 7d, 30d (default: 1d)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServiceHistoryResponse]
// @Router /service/{id}/history [get]
func getServiceHistory(c *gin.Context) (*model.ServiceHistoryResponse, error) {
idStr := c.Param("id")
serviceID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
// 检查服务是否存在
service, ok := singleton.ServiceSentinelShared.Get(serviceID)
if !ok || service == nil {
return nil, singleton.Localizer.ErrorT("service not found")
}
// 解析时间范围
periodStr := c.DefaultQuery("period", "1d")
period, err := tsdb.ParseQueryPeriod(periodStr)
if err != nil {
return nil, err
}
// 权限检查:未登录用户只能查看 1d 数据
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
if !isMember && period != tsdb.Period1Day {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
response := &model.ServiceHistoryResponse{
ServiceID: serviceID,
ServiceName: service.Name,
Servers: make([]model.ServerServiceStats, 0),
}
if !singleton.TSDBEnabled() {
return queryServiceHistoryFromDB(serviceID, period, response)
}
result, err := singleton.TSDBShared.QueryServiceHistory(serviceID, period)
if err != nil {
return nil, err
}
serverMap := singleton.ServerShared.GetList()
for i := range result.Servers {
if server, ok := serverMap[result.Servers[i].ServerID]; ok {
result.Servers[i].ServerName = server.Name
}
}
response.Servers = result.Servers
return response, nil
}
func queryServiceHistoryFromDB(serviceID uint64, period tsdb.QueryPeriod, response *model.ServiceHistoryResponse) (*model.ServiceHistoryResponse, error) {
since := time.Now().Add(-period.Duration())
var histories []model.ServiceHistory
if err := singleton.DB.Where("service_id = ? AND server_id != 0 AND created_at >= ?", serviceID, since).
Order("server_id, created_at").Find(&histories).Error; err != nil {
return nil, err
}
serverMap := singleton.ServerShared.GetList()
grouped := make(map[uint64][]model.ServiceHistory)
for _, h := range histories {
grouped[h.ServerID] = append(grouped[h.ServerID], h)
}
for serverID, records := range grouped {
stats := model.ServerServiceStats{
ServerID: serverID,
}
if server, ok := serverMap[serverID]; ok {
stats.ServerName = server.Name
}
var totalDelay float64
var totalUp, totalDown uint64
dps := make([]model.DataPoint, 0, len(records))
for _, r := range records {
status := uint8(1)
if r.Down > 0 && r.Up == 0 {
status = 0
}
dps = append(dps, model.DataPoint{
Timestamp: r.CreatedAt.Unix() * 1000,
Delay: r.AvgDelay,
Status: status,
})
totalDelay += r.AvgDelay
totalUp += r.Up
totalDown += r.Down
}
var avgDelay float64
if len(records) > 0 {
avgDelay = totalDelay / float64(len(records))
}
var upPercent float32
if totalUp+totalDown > 0 {
upPercent = float32(totalUp) / float32(totalUp+totalDown) * 100
}
stats.Stats = model.ServiceHistorySummary{
AvgDelay: avgDelay,
UpPercent: upPercent,
TotalUp: totalUp,
TotalDown: totalDown,
DataPoints: dps,
}
response.Servers = append(response.Servers, stats)
}
return response, nil
}
// List server services
// @Summary List service histories by server id // @Summary List service histories by server id
// @Security BearerAuth // @Security BearerAuth
// @Schemes // @Schemes
// @Description List service histories by server id // @Description List service histories for a specific server
// @Tags common // @Tags common
// @param id path uint true "Server ID" // @param id path uint true "Server ID"
// @param period query string false "Time period: 1d, 7d, 30d (default: 1d)"
// @Produce json // @Produce json
// @Success 200 {object} model.CommonResponse[[]model.ServiceInfos] // @Success 200 {object} model.CommonResponse[[]model.ServiceInfos]
// @Router /service/{id} [get] // @Router /server/{id}/service [get]
func listServiceHistory(c *gin.Context) ([]*model.ServiceInfos, error) { func listServerServices(c *gin.Context) ([]*model.ServiceInfos, error) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64) serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil { if err != nil {
return nil, err return nil, err
} }
m := singleton.ServerShared.GetList() m := singleton.ServerShared.GetList()
server, ok := m[id] server, ok := m[serverID]
if !ok || server == nil { if !ok || server == nil {
return nil, singleton.Localizer.ErrorT("server not found") return nil, singleton.Localizer.ErrorT("server not found")
} }
_, isMember := c.Get(model.CtxKeyAuthorizedUser) _, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied authorized := isMember
if server.HideForGuest && !authorized { if server.HideForGuest && !authorized {
return nil, singleton.Localizer.ErrorT("unauthorized") return nil, singleton.Localizer.ErrorT("unauthorized")
} }
var serviceHistories []*model.ServiceHistory // 解析时间范围
if err := singleton.DB.Model(&model.ServiceHistory{}).Select("service_id, created_at, server_id, avg_delay"). periodStr := c.DefaultQuery("period", "1d")
Where("server_id = ?", id).Where("created_at >= ?", time.Now().Add(-24*time.Hour)).Order("service_id, created_at"). period, err := tsdb.ParseQueryPeriod(periodStr)
Scan(&serviceHistories).Error; err != nil { if err != nil {
return nil, err return nil, err
} }
var sortedServiceIDs []uint64 // 权限检查:未登录用户只能查看 1d 数据
resultMap := make(map[uint64]*model.ServiceInfos) if !isMember && period != tsdb.Period1Day {
for _, history := range serviceHistories { return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
infos, ok := resultMap[history.ServiceID] }
service, _ := singleton.ServiceSentinelShared.Get(history.ServiceID)
if !ok { services := singleton.ServiceSentinelShared.GetSortedList()
infos = &model.ServiceInfos{
ServiceID: history.ServiceID, var result []*model.ServiceInfos
ServerID: history.ServerID,
ServiceName: service.Name, if !singleton.TSDBEnabled() {
ServerName: m[history.ServerID].Name, return queryServerServicesFromDB(serverID, server.Name, period, services)
}
historyResults, err := singleton.TSDBShared.QueryServiceHistoryByServerID(serverID, period)
if err != nil {
return nil, err
}
for _, service := range services {
if service.Cover == model.ServiceCoverAll {
if service.SkipServers[serverID] {
continue
}
} else {
if !service.SkipServers[serverID] {
continue
} }
resultMap[history.ServiceID] = infos
sortedServiceIDs = append(sortedServiceIDs, history.ServiceID)
} }
infos.CreatedAt = append(infos.CreatedAt, history.CreatedAt.Truncate(time.Minute).Unix()*1000)
infos.AvgDelay = append(infos.AvgDelay, history.AvgDelay) historyResult, ok := historyResults[service.ID]
if !ok || len(historyResult.Servers) == 0 {
continue
}
serverStats := historyResult.Servers[0]
infos := &model.ServiceInfos{
ServiceID: service.ID,
ServerID: serverID,
ServiceName: service.Name,
ServerName: server.Name,
DisplayIndex: service.DisplayIndex,
CreatedAt: make([]int64, len(serverStats.Stats.DataPoints)),
AvgDelay: make([]float64, len(serverStats.Stats.DataPoints)),
}
for i, dp := range serverStats.Stats.DataPoints {
infos.CreatedAt[i] = dp.Timestamp
infos.AvgDelay[i] = dp.Delay
}
result = append(result, infos)
} }
ret := make([]*model.ServiceInfos, 0, len(sortedServiceIDs)) return result, nil
for _, id := range sortedServiceIDs { }
ret = append(ret, resultMap[id])
func queryServerServicesFromDB(serverID uint64, serverName string, period tsdb.QueryPeriod, services []*model.Service) ([]*model.ServiceInfos, error) {
since := time.Now().Add(-period.Duration())
var histories []model.ServiceHistory
if err := singleton.DB.Where("server_id = ? AND created_at >= ?", serverID, since).
Order("service_id, created_at").Find(&histories).Error; err != nil {
return nil, err
} }
return ret, nil grouped := make(map[uint64][]model.ServiceHistory)
for _, h := range histories {
grouped[h.ServiceID] = append(grouped[h.ServiceID], h)
}
var result []*model.ServiceInfos
for _, service := range services {
if service.Cover == model.ServiceCoverAll {
if service.SkipServers[serverID] {
continue
}
} else {
if !service.SkipServers[serverID] {
continue
}
}
records, ok := grouped[service.ID]
if !ok {
continue
}
infos := &model.ServiceInfos{
ServiceID: service.ID,
ServerID: serverID,
ServiceName: service.Name,
ServerName: serverName,
DisplayIndex: service.DisplayIndex,
CreatedAt: make([]int64, 0, len(records)),
AvgDelay: make([]float64, 0, len(records)),
}
for _, r := range records {
infos.CreatedAt = append(infos.CreatedAt, r.CreatedAt.Truncate(time.Minute).Unix()*1000)
infos.AvgDelay = append(infos.AvgDelay, r.AvgDelay)
}
result = append(result, infos)
}
return result, nil
} }
// List server with service // List server with service
// @Summary List server with service // @Summary List server with service
// @Security BearerAuth // @Security BearerAuth
// @Schemes // @Schemes
// @Description List server with service // @Description List servers that have service monitoring data
// @Tags common // @Tags common
// @Produce json // @Produce json
// @Success 200 {object} model.CommonResponse[[]uint64] // @Success 200 {object} model.CommonResponse[[]uint64]
// @Router /service/server [get] // @Router /service/server [get]
func listServerWithServices(c *gin.Context) ([]uint64, error) { func listServerWithServices(c *gin.Context) ([]uint64, error) {
var serverIdsWithService []uint64 // 从内存中获取有服务监控配置的服务器列表
if err := singleton.DB.Model(&model.ServiceHistory{}). services := singleton.ServiceSentinelShared.GetList()
Select("distinct(server_id)"). serverMap := singleton.ServerShared.GetList()
Where("server_id != 0").
Find(&serverIdsWithService).Error; err != nil { serverIDSet := make(map[uint64]bool)
return nil, newGormError("%v", err)
for _, service := range services {
if service.Cover == model.ServiceCoverAll {
// 除了跳过的服务器,其他都包含
for serverID := range serverMap {
if !service.SkipServers[serverID] {
serverIDSet[serverID] = true
}
}
} else {
// 只包含指定的服务器
for serverID, enabled := range service.SkipServers {
if enabled {
serverIDSet[serverID] = true
}
}
}
} }
_, isMember := c.Get(model.CtxKeyAuthorizedUser) _, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied authorized := isMember
var ret []uint64 var ret []uint64
for _, id := range serverIdsWithService { for id := range serverIDSet {
server, ok := singleton.ServerShared.Get(id) server, ok := serverMap[id]
if !ok || server == nil { if !ok || server == nil {
return nil, singleton.Localizer.ErrorT("server not found") continue
} }
if !server.HideForGuest || authorized { if !server.HideForGuest || authorized {
ret = append(ret, id) ret = append(ret, id)
@@ -191,6 +416,7 @@ func createService(c *gin.Context) (uint64, error) {
m.Type = mf.Type m.Type = mf.Type
m.SkipServers = mf.SkipServers m.SkipServers = mf.SkipServers
m.Cover = mf.Cover m.Cover = mf.Cover
m.DisplayIndex = mf.DisplayIndex
m.Notify = mf.Notify m.Notify = mf.Notify
m.NotificationGroupID = mf.NotificationGroupID m.NotificationGroupID = mf.NotificationGroupID
m.Duration = mf.Duration m.Duration = mf.Duration
@@ -210,21 +436,6 @@ func createService(c *gin.Context) (uint64, error) {
return 0, newGormError("%v", err) return 0, newGormError("%v", err)
} }
var skipServers []uint64
for k := range m.SkipServers {
skipServers = append(skipServers, k)
}
var err error
if m.Cover == 0 {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id in (?)", m.ID, skipServers).Error
} else {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id not in (?)", m.ID, skipServers).Error
}
if err != nil {
return 0, err
}
if err := singleton.ServiceSentinelShared.Update(&m); err != nil { if err := singleton.ServiceSentinelShared.Update(&m); err != nil {
return 0, err return 0, err
} }
@@ -269,6 +480,7 @@ func updateService(c *gin.Context) (any, error) {
m.Type = mf.Type m.Type = mf.Type
m.SkipServers = mf.SkipServers m.SkipServers = mf.SkipServers
m.Cover = mf.Cover m.Cover = mf.Cover
m.DisplayIndex = mf.DisplayIndex
m.Notify = mf.Notify m.Notify = mf.Notify
m.NotificationGroupID = mf.NotificationGroupID m.NotificationGroupID = mf.NotificationGroupID
m.Duration = mf.Duration m.Duration = mf.Duration
@@ -288,17 +500,6 @@ func updateService(c *gin.Context) (any, error) {
return nil, newGormError("%v", err) return nil, newGormError("%v", err)
} }
skipServers := utils.MapKeysToSlice(mf.SkipServers)
if m.Cover == model.ServiceCoverAll {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id in (?)", m.ID, skipServers).Error
} else {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id not in (?) and server_id > 0", m.ID, skipServers).Error
}
if err != nil {
return nil, err
}
if err := singleton.ServiceSentinelShared.Update(&m); err != nil { if err := singleton.ServiceSentinelShared.Update(&m); err != nil {
return nil, err return nil, err
} }
@@ -329,10 +530,7 @@ func batchDeleteService(c *gin.Context) (any, error) {
} }
err := singleton.DB.Transaction(func(tx *gorm.DB) error { err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.Service{}, "id in (?)", ids).Error; err != nil { return tx.Unscoped().Delete(&model.Service{}, "id in (?)", ids).Error
return err
}
return tx.Unscoped().Delete(&model.ServiceHistory{}, "service_id in (?)", ids).Error
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+16
View File
@@ -39,6 +39,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
}, },
Version: singleton.Version, Version: singleton.Version,
FrontendTemplates: singleton.FrontendTemplates, FrontendTemplates: singleton.FrontendTemplates,
TSDBEnabled: singleton.TSDBEnabled(),
} }
if !authorized || !isAdmin { if !authorized || !isAdmin {
@@ -54,6 +55,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
ConfigDashboard: configDashboard, ConfigDashboard: configDashboard,
Oauth2Providers: config.Oauth2Providers, Oauth2Providers: config.Oauth2Providers,
}, },
TSDBEnabled: singleton.TSDBEnabled(),
} }
} }
@@ -113,3 +115,17 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.OnUpdateLang(singleton.Conf.Language) singleton.OnUpdateLang(singleton.Conf.Language)
return nil, nil return nil, nil
} }
// Perform maintenance
// @Summary Perform maintenance
// @Security BearerAuth
// @Schemes
// @Description Perform system maintenance (SQLite VACUUM and TSDB maintenance)
// @Tags admin required
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /maintenance [post]
func runMaintenance(c *gin.Context) (any, error) {
singleton.PerformMaintenance()
return nil, nil
}
+14 -5
View File
@@ -11,6 +11,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"runtime/debug"
"strings" "strings"
"time" "time"
_ "time/tzdata" _ "time/tzdata"
@@ -65,8 +66,8 @@ func initSystem(bus chan<- *model.Service) error {
return err return err
} }
// 每天的3:30 对 监控记录 和 流量记录 进行清理 // 每天的3:30 对流量记录进行清理
if _, err := singleton.CronShared.AddFunc("0 30 3 * * *", singleton.CleanServiceHistory); err != nil { if _, err := singleton.CronShared.AddFunc("0 30 3 * * *", singleton.CleanMonitorHistory); err != nil {
return err return err
} }
@@ -109,12 +110,19 @@ func main() {
os.Exit(0) os.Exit(0)
} }
serviceSentinelDispatchBus := make(chan *model.Service) // 用于传递服务监控任务信息的channel serviceSentinelDispatchBus := make(chan *model.Service)
// 初始化 dao 包
if err := utils.FirstError(singleton.InitFrontendTemplates, if err := utils.FirstError(singleton.InitFrontendTemplates,
func() error { return singleton.InitConfigFromPath(dashboardCliParam.ConfigFile) }, func() error { return singleton.InitConfigFromPath(dashboardCliParam.ConfigFile) },
singleton.InitTimezoneAndCache, singleton.InitTimezoneAndCache,
func() error {
if singleton.Conf.Memory.GoMemLimitMB > 0 {
debug.SetMemoryLimit(singleton.Conf.Memory.GoMemLimitMB * 1024 * 1024)
log.Printf("NEZHA>> Go memory limit set to %d MB", singleton.Conf.Memory.GoMemLimitMB)
}
return nil
},
func() error { return singleton.InitDBFromPath(dashboardCliParam.DatabaseLocation) }, func() error { return singleton.InitDBFromPath(dashboardCliParam.DatabaseLocation) },
singleton.InitTSDB,
func() error { return initSystem(serviceSentinelDispatchBus) }); err != nil { func() error { return initSystem(serviceSentinelDispatchBus) }); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -124,7 +132,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
singleton.CleanServiceHistory() singleton.CleanMonitorHistory()
rpc.DispatchKeepalive() rpc.DispatchKeepalive()
go rpc.DispatchTask(serviceSentinelDispatchBus) go rpc.DispatchTask(serviceSentinelDispatchBus)
go singleton.AlertSentinelStart() go singleton.AlertSentinelStart()
@@ -172,6 +180,7 @@ func main() {
}, func(c context.Context) error { }, func(c context.Context) error {
log.Println("NEZHA>> Graceful::START") log.Println("NEZHA>> Graceful::START")
singleton.RecordTransferHourlyUsage() singleton.RecordTransferHourlyUsage()
singleton.CloseTSDB()
log.Println("NEZHA>> Graceful::END") log.Println("NEZHA>> Graceful::END")
var err error var err error
if muxServerHTTPS != nil { if muxServerHTTPS != nil {
+25 -11
View File
@@ -1,8 +1,9 @@
module github.com/nezhahq/nezha module github.com/nezhahq/nezha
go 1.25 go 1.25.6
require ( require (
github.com/VictoriaMetrics/VictoriaMetrics v1.134.0
github.com/appleboy/gin-jwt/v2 v2.10.3 github.com/appleboy/gin-jwt/v2 v2.10.3
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
github.com/gin-contrib/pprof v1.5.3 github.com/gin-contrib/pprof v1.5.3
@@ -15,7 +16,7 @@ require (
github.com/knadh/koanf/maps v0.1.2 github.com/knadh/koanf/maps v0.1.2
github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/v2 v2.2.0 github.com/knadh/koanf/v2 v2.3.0
github.com/leonelquinteros/gotext v1.7.1 github.com/leonelquinteros/gotext v1.7.1
github.com/libdns/cloudflare v0.2.1 github.com/libdns/cloudflare v0.2.1
github.com/libdns/he v1.1.1 github.com/libdns/he v1.1.1
@@ -26,17 +27,18 @@ require (
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
golang.org/x/net v0.47.0 golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.29.0 golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.18.0
google.golang.org/grpc v1.72.0 google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.6 google.golang.org/protobuf v1.36.10
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.0 gorm.io/gorm v1.26.0
sigs.k8s.io/yaml v1.4.0 sigs.k8s.io/yaml v1.4.0
@@ -44,8 +46,13 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/VictoriaMetrics/easyproto v1.1.3 // indirect
github.com/VictoriaMetrics/fastcache v1.13.2 // indirect
github.com/VictoriaMetrics/metrics v1.40.2 // indirect
github.com/VictoriaMetrics/metricsql v0.84.8 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -59,10 +66,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect
@@ -71,23 +80,28 @@ require (
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/swaggo/swag v1.16.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastrand v1.1.0 // indirect
github.com/valyala/gozstd v1.24.0 // indirect
github.com/valyala/histogram v1.2.0 // indirect
github.com/valyala/quicktemplate v1.8.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.16.0 // indirect golang.org/x/arch v0.16.0 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+66 -35
View File
@@ -1,5 +1,17 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/VictoriaMetrics/VictoriaMetrics v1.134.0 h1:0FgGM0rVRcTzd9qtO1gHlgLC/kBA1gsi+iwSYXAa/rQ=
github.com/VictoriaMetrics/VictoriaMetrics v1.134.0/go.mod h1:vUnt83zBB65TkVq7zSjSuJFUZFwnmV+hdF2KmkUbY0U=
github.com/VictoriaMetrics/easyproto v1.1.3 h1:gRSA3ZQs7n4+5I+SniDWD59jde1jVq4JmgQ9HUUyvk4=
github.com/VictoriaMetrics/easyproto v1.1.3/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710=
github.com/VictoriaMetrics/fastcache v1.13.2 h1:2XTB49aLSuCex7e9P5rqrfQcMkzGjh5Vq3GMFa8YpCA=
github.com/VictoriaMetrics/fastcache v1.13.2/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
github.com/VictoriaMetrics/metricsql v0.84.8 h1:5JXrvPJiYkYNqJVT7+hMZmpAwRHd3txBdlVIw4rJ1VM=
github.com/VictoriaMetrics/metricsql v0.84.8/go.mod h1:d4EisFO6ONP/HIGDYTAtwrejJBBeKGQYiRl095bS4QQ=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc= github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc=
github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8= github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8=
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
@@ -9,6 +21,8 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
@@ -30,8 +44,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -58,9 +72,11 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -78,6 +94,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -87,8 +105,8 @@ github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWy
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -119,8 +137,9 @@ github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a h1:wCB9wDZi2JlTfMtE09s5VjSaQpk4EXegvja4wEzx2vk= github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a h1:wCB9wDZi2JlTfMtE09s5VjSaQpk4EXegvja4wEzx2vk=
github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a/go.mod h1:CUbNGv2k24auuhwa7MMVXl45fniBMm2eVi57FlWLcIs= github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a/go.mod h1:CUbNGv2k24auuhwa7MMVXl45fniBMm2eVi57FlWLcIs=
github.com/ory/graceful v0.1.3 h1:FaeXcHZh168WzS+bqruqWEw/HgXWLdNv2nJ+fbhxbhc= github.com/ory/graceful v0.1.3 h1:FaeXcHZh168WzS+bqruqWEw/HgXWLdNv2nJ+fbhxbhc=
@@ -138,8 +157,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -149,8 +168,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
@@ -171,29 +190,39 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/gozstd v1.24.0 h1:M/9L3h7bVwbj2gZwrmuoaxzwVrmBUvos2jG9cZtuhlc=
github.com/valyala/gozstd v1.24.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
github.com/valyala/quicktemplate v1.8.0 h1:zU0tjbIqTRgKQzFY1L42zq0qR3eh4WoQQdIdqCysW5k=
github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
@@ -203,8 +232,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
@@ -227,20 +256,22 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+22
View File
@@ -70,6 +70,12 @@ type Config struct {
// HTTPS 配置 // HTTPS 配置
HTTPS HTTPSConf `koanf:"https" json:"https"` HTTPS HTTPSConf `koanf:"https" json:"https"`
// TSDB 配置
TSDB TSDBConf `koanf:"tsdb" json:"tsdb"`
// 内存配置
Memory MemoryConf `koanf:"memory" json:"memory"`
k *koanf.Koanf `json:"-"` k *koanf.Koanf `json:"-"`
filePath string `json:"-"` filePath string `json:"-"`
} }
@@ -81,6 +87,22 @@ type HTTPSConf struct {
TLSKeyPath string `koanf:"tls_key_path" json:"tls_key_path,omitempty"` TLSKeyPath string `koanf:"tls_key_path" json:"tls_key_path,omitempty"`
} }
// TSDBConf TSDB 配置
type TSDBConf struct {
DataPath string `koanf:"data_path" json:"data_path,omitempty"`
RetentionDays uint16 `koanf:"retention_days" json:"retention_days,omitempty"`
MinFreeDiskSpaceGB float64 `koanf:"min_free_disk_space_gb" json:"min_free_disk_space_gb,omitempty"`
MaxMemoryMB int64 `koanf:"max_memory_mb" json:"max_memory_mb,omitempty"`
WriteBufferSize int `koanf:"write_buffer_size" json:"write_buffer_size,omitempty"`
WriteBufferFlushInterval int `koanf:"write_buffer_flush_interval" json:"write_buffer_flush_interval,omitempty"`
}
// MemoryConf 内存配置
type MemoryConf struct {
// GoMemLimitMB Go 运行时内存限制(MB),0 表示不限制
GoMemLimitMB int64 `koanf:"go_mem_limit_mb" json:"go_mem_limit_mb,omitempty"`
}
// Read 读取配置文件并应用 // Read 读取配置文件并应用
func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error { func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
c.k = koanf.New(".") c.k = koanf.New(".")
+1
View File
@@ -53,6 +53,7 @@ type Service struct {
Target string `json:"target"` Target string `json:"target"`
SkipServersRaw string `json:"-"` SkipServersRaw string `json:"-"`
Duration uint64 `json:"duration"` Duration uint64 `json:"duration"`
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
Notify bool `json:"notify,omitempty"` Notify bool `json:"notify,omitempty"`
NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID
Cover uint8 `json:"cover"` Cover uint8 `json:"cover"`
+2 -1
View File
@@ -7,6 +7,7 @@ type ServiceForm struct {
Target string `json:"target,omitempty"` Target string `json:"target,omitempty"`
Type uint8 `json:"type,omitempty"` Type uint8 `json:"type,omitempty"`
Cover uint8 `json:"cover,omitempty"` Cover uint8 `json:"cover,omitempty"`
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
Notify bool `json:"notify,omitempty" validate:"optional"` Notify bool `json:"notify,omitempty" validate:"optional"`
Duration uint64 `json:"duration,omitempty"` Duration uint64 `json:"duration,omitempty"`
MinLatency float32 `json:"min_latency,omitempty" default:"0.0"` MinLatency float32 `json:"min_latency,omitempty" default:"0.0"`
@@ -26,7 +27,7 @@ type ServiceResponseItem struct {
CurrentDown uint64 `json:"current_down"` CurrentDown uint64 `json:"current_down"`
TotalUp uint64 `json:"total_up"` TotalUp uint64 `json:"total_up"`
TotalDown uint64 `json:"total_down"` TotalDown uint64 `json:"total_down"`
Delay *[30]float32 `json:"delay,omitempty"` Delay *[30]float64 `json:"delay,omitempty"`
Up *[30]uint64 `json:"up,omitempty"` Up *[30]uint64 `json:"up,omitempty"`
Down *[30]uint64 `json:"down,omitempty"` Down *[30]uint64 `json:"down,omitempty"`
} }
+1 -1
View File
@@ -10,7 +10,7 @@ type ServiceHistory struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
ServiceID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"service_id,omitempty"` ServiceID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"service_id,omitempty"`
ServerID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"server_id,omitempty"` ServerID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"server_id,omitempty"`
AvgDelay float32 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"avg_delay,omitempty"` // 平均延迟,毫秒 AvgDelay float64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"avg_delay,omitempty"` // 平均延迟,毫秒
Up uint64 `json:"up,omitempty"` // 检查状态良好计数 Up uint64 `json:"up,omitempty"` // 检查状态良好计数
Down uint64 `json:"down,omitempty"` // 检查状态异常计数 Down uint64 `json:"down,omitempty"` // 检查状态异常计数
Data string `json:"data,omitempty"` Data string `json:"data,omitempty"`
+52 -6
View File
@@ -1,10 +1,56 @@
package model package model
// ServiceInfos 服务监控信息(兼容旧API)
type ServiceInfos struct { type ServiceInfos struct {
ServiceID uint64 `json:"monitor_id"` ServiceID uint64 `json:"monitor_id"`
ServerID uint64 `json:"server_id"` ServerID uint64 `json:"server_id"`
ServiceName string `json:"monitor_name"` ServiceName string `json:"monitor_name"`
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
CreatedAt []int64 `json:"created_at"` DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
AvgDelay []float32 `json:"avg_delay"` CreatedAt []int64 `json:"created_at"`
AvgDelay []float64 `json:"avg_delay"`
}
// DataPoint 数据点
type DataPoint struct {
Timestamp int64 `json:"ts"`
Delay float64 `json:"delay"`
Status uint8 `json:"status"` // 1=成功, 0=失败
}
// ServiceHistorySummary 服务历史统计摘要
type ServiceHistorySummary struct {
AvgDelay float64 `json:"avg_delay"`
UpPercent float32 `json:"up_percent"`
TotalUp uint64 `json:"total_up"`
TotalDown uint64 `json:"total_down"`
DataPoints []DataPoint `json:"data_points,omitempty"`
}
// ServerServiceStats 某服务器对某服务的统计
type ServerServiceStats struct {
ServerID uint64 `json:"server_id"`
ServerName string `json:"server_name,omitempty"`
Stats ServiceHistorySummary `json:"stats"`
}
// ServiceHistoryResponse 服务历史查询响应
type ServiceHistoryResponse struct {
ServiceID uint64 `json:"service_id"`
ServiceName string `json:"service_name,omitempty"`
Servers []ServerServiceStats `json:"servers"`
}
// ServerMetricsDataPoint 服务器指标数据点
type ServerMetricsDataPoint struct {
Timestamp int64 `json:"ts"`
Value float64 `json:"value"`
}
// ServerMetricsResponse 服务器指标历史查询响应
type ServerMetricsResponse struct {
ServerID uint64 `json:"server_id"`
ServerName string `json:"server_name,omitempty"`
Metric string `json:"metric"`
DataPoints []ServerMetricsDataPoint `json:"data_points"`
} }
+3 -2
View File
@@ -10,8 +10,8 @@ type SettingForm struct {
InstallHost string `json:"install_host,omitempty" validate:"optional"` InstallHost string `json:"install_host,omitempty" validate:"optional"`
CustomCode string `json:"custom_code,omitempty" validate:"optional"` CustomCode string `json:"custom_code,omitempty" validate:"optional"`
CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"` CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
AgentRealIPHeader string `json:"agent_real_ip_header,omitempty" validate:"optional"` // Agent真实IP AgentRealIPHeader string `json:"agent_real_ip_header,omitempty" validate:"optional"` // Agent真实IP
UserTemplate string `json:"user_template,omitempty" validate:"optional"` UserTemplate string `json:"user_template,omitempty" validate:"optional"`
AgentTLS bool `json:"tls,omitempty" validate:"optional"` AgentTLS bool `json:"tls,omitempty" validate:"optional"`
@@ -42,4 +42,5 @@ type SettingResponse struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
FrontendTemplates []FrontendTemplate `json:"frontend_templates,omitempty"` FrontendTemplates []FrontendTemplate `json:"frontend_templates,omitempty"`
TSDBEnabled bool `json:"tsdb_enabled"`
} }
+67
View File
@@ -0,0 +1,67 @@
package tsdb
import "time"
// Config TSDB 配置选项
type Config struct {
// DataPath 数据存储路径,为空则不启用 TSDB
DataPath string `koanf:"data_path" json:"data_path,omitempty"`
// RetentionDays 数据保留天数,默认 30 天
RetentionDays uint16 `koanf:"retention_days" json:"retention_days,omitempty"`
// MinFreeDiskSpaceGB 最小磁盘剩余空间(GB),默认 1GB
// 当磁盘剩余空间低于此值时,TSDB 将停止接收新数据以防止磁盘耗尽
MinFreeDiskSpaceGB float64 `koanf:"min_free_disk_space_gb" json:"min_free_disk_space_gb,omitempty"`
// MaxMemoryMB 最大内存使用量(MB),默认 256MB,用于限制 VictoriaMetrics 缓存
MaxMemoryMB int64 `koanf:"max_memory_mb" json:"max_memory_mb,omitempty"`
// DedupInterval 去重间隔,默认 30 秒
DedupInterval time.Duration `koanf:"dedup_interval" json:"dedup_interval,omitempty"`
// WriteBufferSize 写入缓冲区大小,默认 512,达到此数量后批量写入
WriteBufferSize int `koanf:"write_buffer_size" json:"write_buffer_size,omitempty"`
// WriteBufferFlushInterval 写入缓冲区刷新间隔,默认 5 秒
WriteBufferFlushInterval time.Duration `koanf:"write_buffer_flush_interval" json:"write_buffer_flush_interval,omitempty"`
}
// DefaultConfig 返回默认配置(不设置 DataPath,需要显式配置才启用)
func DefaultConfig() *Config {
return &Config{
DataPath: "",
RetentionDays: 30,
MinFreeDiskSpaceGB: 1,
MaxMemoryMB: 256,
DedupInterval: 30 * time.Second,
WriteBufferSize: 512,
WriteBufferFlushInterval: 5 * time.Second,
}
}
// Validate 验证配置有效性并填充默认值
func (c *Config) Validate() {
if c.RetentionDays == 0 {
c.RetentionDays = 30
}
if c.MinFreeDiskSpaceGB <= 0 {
c.MinFreeDiskSpaceGB = 1
}
if c.MaxMemoryMB <= 0 {
c.MaxMemoryMB = 256
}
if c.DedupInterval <= 0 {
c.DedupInterval = 30 * time.Second
}
if c.WriteBufferSize <= 0 {
c.WriteBufferSize = 512
}
if c.WriteBufferFlushInterval <= 0 {
c.WriteBufferFlushInterval = 5 * time.Second
}
}
// Enabled 检查是否启用 TSDB
func (c *Config) Enabled() bool {
return c.DataPath != ""
}
// MinFreeDiskSpaceBytes 返回最小磁盘剩余空间(字节)
func (c *Config) MinFreeDiskSpaceBytes() int64 {
return int64(c.MinFreeDiskSpaceGB * 1024 * 1024 * 1024)
}
+17
View File
@@ -0,0 +1,17 @@
package tsdb
import (
"log"
)
func (db *TSDB) Maintenance() {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return
}
log.Println("NEZHA>> TSDB starting maintenance (flush)...")
db.storage.DebugFlush()
log.Println("NEZHA>> TSDB maintenance completed")
}
+664
View File
@@ -0,0 +1,664 @@
package tsdb
import (
"fmt"
"log"
"sort"
"strconv"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
"github.com/nezhahq/nezha/model"
)
// QueryPeriod 查询时间段
type QueryPeriod string
const (
Period1Day QueryPeriod = "1d"
Period7Days QueryPeriod = "7d"
Period30Days QueryPeriod = "30d"
)
// ParseQueryPeriod 解析查询时间段
func ParseQueryPeriod(s string) (QueryPeriod, error) {
switch s {
case "1d", "":
return Period1Day, nil
case "7d":
return Period7Days, nil
case "30d":
return Period30Days, nil
default:
return "", fmt.Errorf("invalid period: %s, expected 1d, 7d, or 30d", s)
}
}
// Duration 返回时间段的时长
func (p QueryPeriod) Duration() time.Duration {
switch p {
case Period7Days:
return 7 * 24 * time.Hour
case Period30Days:
return 30 * 24 * time.Hour
default:
return 24 * time.Hour
}
}
// DownsampleInterval 返回降采样间隔
// 1d: 5分钟一个点 (288个点)
// 7d: 30分钟一个点 (336个点)
// 30d: 2小时一个点 (360个点)
func (p QueryPeriod) DownsampleInterval() time.Duration {
switch p {
case Period7Days:
return 30 * time.Minute
case Period30Days:
return 2 * time.Hour
default:
return 5 * time.Minute
}
}
// Type aliases for model types used in tsdb package
type (
DataPoint = model.DataPoint
ServiceHistorySummary = model.ServiceHistorySummary
ServerServiceStats = model.ServerServiceStats
ServiceHistoryResult = model.ServiceHistoryResponse
MetricDataPoint = model.ServerMetricsDataPoint
)
type rawDataPoint struct {
timestamp int64
value float64
status float64
hasDelay bool
hasStatus bool
}
func (db *TSDB) QueryServiceHistory(serviceID uint64, period QueryPeriod) (*ServiceHistoryResult, error) {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return nil, fmt.Errorf("TSDB is closed")
}
now := time.Now()
tr := storage.TimeRange{
MinTimestamp: now.Add(-period.Duration()).UnixMilli(),
MaxTimestamp: now.UnixMilli(),
}
serviceIDStr := strconv.FormatUint(serviceID, 10)
delayData, err := db.queryMetricByServiceID(MetricServiceDelay, serviceIDStr, tr)
if err != nil {
return nil, fmt.Errorf("failed to query delay data: %w", err)
}
statusData, err := db.queryMetricByServiceID(MetricServiceStatus, serviceIDStr, tr)
if err != nil {
return nil, fmt.Errorf("failed to query status data: %w", err)
}
result := &ServiceHistoryResult{
ServiceID: serviceID,
Servers: make([]ServerServiceStats, 0),
}
serverDataMap := make(map[uint64]map[int64]*rawDataPoint)
for serverID, points := range delayData {
if serverDataMap[serverID] == nil {
serverDataMap[serverID] = make(map[int64]*rawDataPoint)
}
for _, p := range points {
serverDataMap[serverID][p.timestamp] = &rawDataPoint{
timestamp: p.timestamp,
value: p.value,
hasDelay: true,
}
}
}
for serverID, points := range statusData {
if serverDataMap[serverID] == nil {
serverDataMap[serverID] = make(map[int64]*rawDataPoint)
}
for _, p := range points {
if existing, ok := serverDataMap[serverID][p.timestamp]; ok {
existing.status = p.value
existing.hasStatus = true
} else {
serverDataMap[serverID][p.timestamp] = &rawDataPoint{
timestamp: p.timestamp,
status: p.value,
hasStatus: true,
}
}
}
}
for serverID, pointsMap := range serverDataMap {
points := make([]rawDataPoint, 0, len(pointsMap))
for _, p := range pointsMap {
points = append(points, *p)
}
stats := calculateStats(points, period.DownsampleInterval())
result.Servers = append(result.Servers, ServerServiceStats{
ServerID: serverID,
Stats: stats,
})
}
sort.Slice(result.Servers, func(i, j int) bool {
return result.Servers[i].ServerID < result.Servers[j].ServerID
})
return result, nil
}
type DailyServiceStats struct {
Up uint64
Down uint64
Delay float64
}
func (db *TSDB) QueryServiceDailyStats(serviceID uint64, today time.Time, days int) ([]DailyServiceStats, error) {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return nil, fmt.Errorf("TSDB is closed")
}
stats := make([]DailyServiceStats, days)
serviceIDStr := strconv.FormatUint(serviceID, 10)
start := today.AddDate(0, 0, -(days - 1))
tr := storage.TimeRange{
MinTimestamp: start.UnixMilli(),
MaxTimestamp: today.UnixMilli(),
}
statusData, err := db.queryMetricByServiceID(MetricServiceStatus, serviceIDStr, tr)
if err != nil {
return nil, err
}
delayData, err := db.queryMetricByServiceID(MetricServiceDelay, serviceIDStr, tr)
if err != nil {
return nil, err
}
for _, points := range statusData {
for _, p := range points {
ts := time.UnixMilli(p.timestamp)
dayIndex := (days - 1) - int(today.Sub(ts).Hours())/24
if dayIndex < 0 || dayIndex >= days {
continue
}
if p.value >= 0.5 {
stats[dayIndex].Up++
} else {
stats[dayIndex].Down++
}
}
}
delayCount := make([]int, days)
for _, points := range delayData {
for _, p := range points {
ts := time.UnixMilli(p.timestamp)
dayIndex := (days - 1) - int(today.Sub(ts).Hours())/24
if dayIndex < 0 || dayIndex >= days {
continue
}
stats[dayIndex].Delay = (stats[dayIndex].Delay*float64(delayCount[dayIndex]) + p.value) / float64(delayCount[dayIndex]+1)
delayCount[dayIndex]++
}
}
return stats, nil
}
type metricPoint struct {
timestamp int64
value float64
}
func (db *TSDB) queryMetricByServiceID(metric MetricType, serviceID string, tr storage.TimeRange) (map[uint64][]metricPoint, error) {
tfs := storage.NewTagFilters()
if err := tfs.Add(nil, []byte(metric), false, false); err != nil {
return nil, err
}
if err := tfs.Add([]byte("service_id"), []byte(serviceID), false, false); err != nil {
return nil, err
}
deadline := uint64(time.Now().Add(30 * time.Second).Unix())
var search storage.Search
search.Init(nil, db.storage, []*storage.TagFilters{tfs}, tr, 100000, deadline)
defer search.MustClose()
result := make(map[uint64][]metricPoint)
var timestamps []int64
var values []float64
for search.NextMetricBlock() {
mbr := search.MetricBlockRef
var block storage.Block
mbr.BlockRef.MustReadBlock(&block)
mn := storage.GetMetricName()
if err := mn.Unmarshal(mbr.MetricName); err != nil {
log.Printf("NEZHA>> TSDB: failed to unmarshal metric name: %v", err)
storage.PutMetricName(mn)
continue
}
serverIDBytes := mn.GetTagValue("server_id")
if len(serverIDBytes) == 0 {
storage.PutMetricName(mn)
continue
}
serverID, err := strconv.ParseUint(string(serverIDBytes), 10, 64)
if err != nil {
log.Printf("NEZHA>> TSDB: failed to parse server_id %q: %v", string(serverIDBytes), err)
storage.PutMetricName(mn)
continue
}
storage.PutMetricName(mn)
if err := block.UnmarshalData(); err != nil {
log.Printf("NEZHA>> TSDB: failed to unmarshal block data: %v", err)
continue
}
timestamps = timestamps[:0]
values = values[:0]
timestamps, values = block.AppendRowsWithTimeRangeFilter(timestamps, values, tr)
for i := range timestamps {
result[serverID] = append(result[serverID], metricPoint{
timestamp: timestamps[i],
value: values[i],
})
}
}
if err := search.Error(); err != nil {
return nil, err
}
return result, nil
}
func calculateStats(points []rawDataPoint, downsampleInterval time.Duration) ServiceHistorySummary {
if len(points) == 0 {
return ServiceHistorySummary{}
}
sort.Slice(points, func(i, j int) bool {
return points[i].timestamp < points[j].timestamp
})
var totalDelay float64
var delayCount int
var totalUp, totalDown uint64
for _, p := range points {
if p.hasDelay {
totalDelay += p.value
delayCount++
}
if p.hasStatus {
if p.status >= 0.5 {
totalUp++
} else {
totalDown++
}
}
}
summary := ServiceHistorySummary{
TotalUp: totalUp,
TotalDown: totalDown,
}
if delayCount > 0 {
summary.AvgDelay = totalDelay / float64(delayCount)
}
if totalUp+totalDown > 0 {
summary.UpPercent = float32(totalUp) / float32(totalUp+totalDown) * 100
}
summary.DataPoints = downsample(points, downsampleInterval)
return summary
}
func downsample(points []rawDataPoint, interval time.Duration) []DataPoint {
if len(points) == 0 {
return nil
}
intervalMs := interval.Milliseconds()
result := make([]DataPoint, 0)
// points 已排序,线性扫描分桶
bucketStart := (points[0].timestamp / intervalMs) * intervalMs
var totalDelay float64
var delayCount, upCount, statusCount int
flushBucket := func() {
var avgDelay float64
if delayCount > 0 {
avgDelay = totalDelay / float64(delayCount)
}
var status uint8
if statusCount > 0 && upCount > statusCount/2 {
status = 1
}
result = append(result, DataPoint{
Timestamp: bucketStart,
Delay: avgDelay,
Status: status,
})
}
for _, p := range points {
key := (p.timestamp / intervalMs) * intervalMs
if key != bucketStart {
flushBucket()
bucketStart = key
totalDelay = 0
delayCount = 0
upCount = 0
statusCount = 0
}
if p.hasDelay {
totalDelay += p.value
delayCount++
}
if p.hasStatus {
statusCount++
if p.status >= 0.5 {
upCount++
}
}
}
flushBucket()
return result
}
func downsampleMetrics(points []rawDataPoint, interval time.Duration, useLastValue bool) []MetricDataPoint {
if len(points) == 0 {
return nil
}
sort.Slice(points, func(i, j int) bool {
return points[i].timestamp < points[j].timestamp
})
intervalMs := interval.Milliseconds()
result := make([]MetricDataPoint, 0)
bucketStart := (points[0].timestamp / intervalMs) * intervalMs
var total float64
var count int
var last rawDataPoint
flushBucket := func() {
var value float64
if useLastValue {
value = last.value
} else if count > 0 {
value = total / float64(count)
}
result = append(result, MetricDataPoint{
Timestamp: bucketStart,
Value: value,
})
}
for _, p := range points {
key := (p.timestamp / intervalMs) * intervalMs
if key != bucketStart {
flushBucket()
bucketStart = key
total = 0
count = 0
}
total += p.value
count++
last = p
}
flushBucket()
return result
}
// isCumulativeMetric 判断指标是否为累积型(单调递增)
func isCumulativeMetric(metric MetricType) bool {
switch metric {
case MetricServerNetInTransfer, MetricServerNetOutTransfer, MetricServerUptime:
return true
default:
return false
}
}
func (db *TSDB) QueryServerMetrics(serverID uint64, metric MetricType, period QueryPeriod) ([]MetricDataPoint, error) {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return nil, fmt.Errorf("TSDB is closed")
}
now := time.Now()
tr := storage.TimeRange{
MinTimestamp: now.Add(-period.Duration()).UnixMilli(),
MaxTimestamp: now.UnixMilli(),
}
serverIDStr := strconv.FormatUint(serverID, 10)
tfs := storage.NewTagFilters()
if err := tfs.Add(nil, []byte(metric), false, false); err != nil {
return nil, err
}
if err := tfs.Add([]byte("server_id"), []byte(serverIDStr), false, false); err != nil {
return nil, err
}
deadline := uint64(time.Now().Add(30 * time.Second).Unix())
var search storage.Search
search.Init(nil, db.storage, []*storage.TagFilters{tfs}, tr, 100000, deadline)
defer search.MustClose()
var points []rawDataPoint
var timestamps []int64
var values []float64
for search.NextMetricBlock() {
mbr := search.MetricBlockRef
var block storage.Block
mbr.BlockRef.MustReadBlock(&block)
if err := block.UnmarshalData(); err != nil {
log.Printf("NEZHA>> TSDB: failed to unmarshal block data: %v", err)
continue
}
timestamps = timestamps[:0]
values = values[:0]
timestamps, values = block.AppendRowsWithTimeRangeFilter(timestamps, values, tr)
for i := range timestamps {
points = append(points, rawDataPoint{
timestamp: timestamps[i],
value: values[i],
})
}
}
if err := search.Error(); err != nil {
return nil, err
}
return downsampleMetrics(points, period.DownsampleInterval(), isCumulativeMetric(metric)), nil
}
func (db *TSDB) QueryServiceHistoryByServerID(serverID uint64, period QueryPeriod) (map[uint64]*ServiceHistoryResult, error) {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return nil, fmt.Errorf("TSDB is closed")
}
now := time.Now()
tr := storage.TimeRange{
MinTimestamp: now.Add(-period.Duration()).UnixMilli(),
MaxTimestamp: now.UnixMilli(),
}
serverIDStr := strconv.FormatUint(serverID, 10)
delayData, err := db.queryMetricByServerID(MetricServiceDelay, serverIDStr, tr)
if err != nil {
return nil, fmt.Errorf("failed to query delay data: %w", err)
}
statusData, err := db.queryMetricByServerID(MetricServiceStatus, serverIDStr, tr)
if err != nil {
return nil, fmt.Errorf("failed to query status data: %w", err)
}
serviceDataMap := make(map[uint64]map[int64]*rawDataPoint)
for serviceID, points := range delayData {
if serviceDataMap[serviceID] == nil {
serviceDataMap[serviceID] = make(map[int64]*rawDataPoint)
}
for _, p := range points {
serviceDataMap[serviceID][p.timestamp] = &rawDataPoint{
timestamp: p.timestamp,
value: p.value,
hasDelay: true,
}
}
}
for serviceID, points := range statusData {
if serviceDataMap[serviceID] == nil {
serviceDataMap[serviceID] = make(map[int64]*rawDataPoint)
}
for _, p := range points {
if existing, ok := serviceDataMap[serviceID][p.timestamp]; ok {
existing.status = p.value
existing.hasStatus = true
} else {
serviceDataMap[serviceID][p.timestamp] = &rawDataPoint{
timestamp: p.timestamp,
status: p.value,
hasStatus: true,
}
}
}
}
results := make(map[uint64]*ServiceHistoryResult)
for serviceID, pointsMap := range serviceDataMap {
points := make([]rawDataPoint, 0, len(pointsMap))
for _, p := range pointsMap {
points = append(points, *p)
}
stats := calculateStats(points, period.DownsampleInterval())
results[serviceID] = &ServiceHistoryResult{
ServiceID: serviceID,
Servers: []ServerServiceStats{{
ServerID: serverID,
Stats: stats,
}},
}
}
return results, nil
}
func (db *TSDB) queryMetricByServerID(metric MetricType, serverID string, tr storage.TimeRange) (map[uint64][]metricPoint, error) {
tfs := storage.NewTagFilters()
if err := tfs.Add(nil, []byte(metric), false, false); err != nil {
return nil, err
}
if err := tfs.Add([]byte("server_id"), []byte(serverID), false, false); err != nil {
return nil, err
}
deadline := uint64(time.Now().Add(30 * time.Second).Unix())
var search storage.Search
search.Init(nil, db.storage, []*storage.TagFilters{tfs}, tr, 100000, deadline)
defer search.MustClose()
result := make(map[uint64][]metricPoint)
var timestamps []int64
var values []float64
for search.NextMetricBlock() {
mbr := search.MetricBlockRef
var block storage.Block
mbr.BlockRef.MustReadBlock(&block)
mn := storage.GetMetricName()
if err := mn.Unmarshal(mbr.MetricName); err != nil {
log.Printf("NEZHA>> TSDB: failed to unmarshal metric name: %v", err)
storage.PutMetricName(mn)
continue
}
serviceIDBytes := mn.GetTagValue("service_id")
if len(serviceIDBytes) == 0 {
storage.PutMetricName(mn)
continue
}
serviceID, err := strconv.ParseUint(string(serviceIDBytes), 10, 64)
if err != nil {
log.Printf("NEZHA>> TSDB: failed to parse service_id %q: %v", string(serviceIDBytes), err)
storage.PutMetricName(mn)
continue
}
storage.PutMetricName(mn)
if err := block.UnmarshalData(); err != nil {
log.Printf("NEZHA>> TSDB: failed to unmarshal block data: %v", err)
continue
}
timestamps = timestamps[:0]
values = values[:0]
timestamps, values = block.AppendRowsWithTimeRangeFilter(timestamps, values, tr)
for i := range timestamps {
result[serviceID] = append(result[serviceID], metricPoint{
timestamp: timestamps[i],
value: values[i],
})
}
}
if err := search.Error(); err != nil {
return nil, err
}
return result, nil
}
+117
View File
@@ -0,0 +1,117 @@
package tsdb
import (
"fmt"
"log"
"path/filepath"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
)
// TSDB 封装 VictoriaMetrics 存储
type TSDB struct {
storage *storage.Storage
config *Config
mu sync.RWMutex
closed bool
writer *bufferedWriter
}
// InitGlobalSettings 初始化 VictoriaMetrics 包级别的全局设置。
// 这些设置是进程级别的,应在 Open() 之前调用且只调用一次。
func InitGlobalSettings(config *Config) {
memBytes := int(config.MaxMemoryMB * 1024 * 1024)
storage.SetTSIDCacheSize(memBytes * 35 / 100)
storage.SetMetricNameCacheSize(memBytes * 10 / 100)
storage.SetTagFiltersCacheSize(memBytes * 5 / 100)
storage.SetMetadataStorageSize(memBytes * 1 / 100)
storage.SetDedupInterval(config.DedupInterval)
storage.SetFreeDiskSpaceLimit(config.MinFreeDiskSpaceBytes())
storage.SetDataFlushInterval(5 * time.Second)
}
// Open 打开或创建 TSDB 存储
func Open(config *Config) (*TSDB, error) {
if config == nil {
config = DefaultConfig()
}
config.Validate()
dataPath := config.DataPath
if !filepath.IsAbs(dataPath) {
absPath, err := filepath.Abs(dataPath)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}
dataPath = absPath
}
InitGlobalSettings(config)
opts := storage.OpenOptions{
Retention: time.Duration(config.RetentionDays) * 24 * time.Hour,
}
stor := storage.MustOpenStorage(dataPath, opts)
db := &TSDB{
storage: stor,
config: config,
}
db.writer = newBufferedWriter(db, config.WriteBufferSize, config.WriteBufferFlushInterval)
log.Printf("NEZHA>> TSDB opened at %s, retention: %d days, min free disk: %.1f GB, max memory: %d MB",
dataPath, config.RetentionDays, config.MinFreeDiskSpaceGB, config.MaxMemoryMB)
return db, nil
}
// Close 关闭 TSDB 存储
func (db *TSDB) Close() error {
db.mu.Lock()
defer db.mu.Unlock()
if db.closed {
return nil
}
if db.writer != nil {
db.writer.stop()
}
db.storage.MustClose()
db.closed = true
log.Println("NEZHA>> TSDB closed")
return nil
}
// Storage 返回底层存储对象(用于高级查询)
func (db *TSDB) Storage() *storage.Storage {
return db.storage
}
// Config 返回配置
func (db *TSDB) Config() *Config {
return db.config
}
// IsClosed 检查是否已关闭
func (db *TSDB) IsClosed() bool {
db.mu.RLock()
defer db.mu.RUnlock()
return db.closed
}
// Flush 强制刷盘(主要用于测试)
func (db *TSDB) Flush() {
if db.writer != nil {
db.writer.flush()
}
db.storage.DebugFlush()
}
+622
View File
@@ -0,0 +1,622 @@
package tsdb
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfig_Defaults(t *testing.T) {
config := DefaultConfig()
assert.Equal(t, "", config.DataPath) // 默认为空,不启用 TSDB
assert.Equal(t, uint16(30), config.RetentionDays)
assert.Equal(t, float64(1), config.MinFreeDiskSpaceGB)
assert.Equal(t, 30*time.Second, config.DedupInterval)
assert.False(t, config.Enabled())
}
func TestConfig_Enabled(t *testing.T) {
config := &Config{DataPath: ""}
assert.False(t, config.Enabled())
config.DataPath = "data/tsdb"
assert.True(t, config.Enabled())
}
func TestConfig_MinFreeDiskSpaceBytes(t *testing.T) {
config := &Config{MinFreeDiskSpaceGB: 5}
expected := int64(5 * 1024 * 1024 * 1024)
assert.Equal(t, expected, config.MinFreeDiskSpaceBytes())
}
func TestTSDB_OpenClose(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
require.NotNil(t, db)
assert.False(t, db.IsClosed())
assert.NotNil(t, db.Storage())
assert.Equal(t, config, db.Config())
err = db.Close()
require.NoError(t, err)
assert.True(t, db.IsClosed())
// 重复关闭应该安全
err = db.Close()
require.NoError(t, err)
}
func TestTSDB_WriteServerMetrics(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
metrics := &ServerMetrics{
ServerID: 1,
Timestamp: time.Now(),
CPU: 50.5,
MemUsed: 1024 * 1024 * 1024,
SwapUsed: 512 * 1024 * 1024,
DiskUsed: 10 * 1024 * 1024 * 1024,
NetInSpeed: 1000000,
NetOutSpeed: 500000,
NetInTransfer: 1000000000,
NetOutTransfer: 500000000,
Load1: 1.5,
Load5: 1.2,
Load15: 1.0,
TCPConnCount: 100,
UDPConnCount: 50,
ProcessCount: 200,
Temperature: 65.5,
Uptime: 86400,
GPU: 30.0,
}
err = db.WriteServerMetrics(metrics)
require.NoError(t, err)
// 强制刷盘
db.Flush()
}
func TestTSDB_WriteServiceMetrics(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
metrics := &ServiceMetrics{
ServiceID: 1,
ServerID: 1,
Timestamp: time.Now(),
Delay: 45.5,
Successful: true,
}
err = db.WriteServiceMetrics(metrics)
require.NoError(t, err)
// 测试失败状态
metrics2 := &ServiceMetrics{
ServiceID: 1,
ServerID: 2,
Timestamp: time.Now(),
Delay: 0,
Successful: false,
}
err = db.WriteServiceMetrics(metrics2)
require.NoError(t, err)
// 强制刷盘
db.Flush()
}
func TestTSDB_WriteBatchMetrics(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
// 批量写入服务器指标
serverMetrics := []*ServerMetrics{
{ServerID: 1, Timestamp: time.Now(), CPU: 10.0},
{ServerID: 2, Timestamp: time.Now(), CPU: 20.0},
{ServerID: 3, Timestamp: time.Now(), CPU: 30.0},
}
err = db.WriteBatchServerMetrics(serverMetrics)
require.NoError(t, err)
// 批量写入服务指标
serviceMetrics := []*ServiceMetrics{
{ServiceID: 1, ServerID: 1, Timestamp: time.Now(), Delay: 10.0, Successful: true},
{ServiceID: 1, ServerID: 2, Timestamp: time.Now(), Delay: 20.0, Successful: true},
{ServiceID: 2, ServerID: 1, Timestamp: time.Now(), Delay: 15.0, Successful: false},
}
err = db.WriteBatchServiceMetrics(serviceMetrics)
require.NoError(t, err)
db.Flush()
}
func TestTSDB_WriteToClosedDB(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
db.Close()
// 写入已关闭的数据库应该返回错误
err = db.WriteServerMetrics(&ServerMetrics{ServerID: 1, Timestamp: time.Now()})
assert.Error(t, err)
err = db.WriteServiceMetrics(&ServiceMetrics{ServiceID: 1, ServerID: 1, Timestamp: time.Now()})
assert.Error(t, err)
}
func TestQueryPeriod_Parse(t *testing.T) {
tests := []struct {
input string
expected QueryPeriod
hasError bool
}{
{"1d", Period1Day, false},
{"7d", Period7Days, false},
{"30d", Period30Days, false},
{"", Period1Day, false},
{"invalid", "", true},
{"1w", "", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
period, err := ParseQueryPeriod(tt.input)
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, period)
}
})
}
}
func TestQueryPeriod_Duration(t *testing.T) {
assert.Equal(t, 24*time.Hour, Period1Day.Duration())
assert.Equal(t, 7*24*time.Hour, Period7Days.Duration())
assert.Equal(t, 30*24*time.Hour, Period30Days.Duration())
}
func TestQueryPeriod_DownsampleInterval(t *testing.T) {
assert.Equal(t, 5*time.Minute, Period1Day.DownsampleInterval())
assert.Equal(t, 30*time.Minute, Period7Days.DownsampleInterval())
assert.Equal(t, 2*time.Hour, Period30Days.DownsampleInterval())
}
func TestTSDB_QueryServiceHistory(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
// 写入测试数据
now := time.Now()
serviceID := uint64(100)
serverID1 := uint64(1)
serverID2 := uint64(2)
// 写入多条服务监控数据
for i := 0; i < 10; i++ {
ts := now.Add(-time.Duration(i) * time.Minute)
// 服务器1的数据:成功
err := db.WriteServiceMetrics(&ServiceMetrics{
ServiceID: serviceID,
ServerID: serverID1,
Timestamp: ts,
Delay: float64(10 + i),
Successful: true,
})
require.NoError(t, err)
// 服务器2的数据:部分失败
err = db.WriteServiceMetrics(&ServiceMetrics{
ServiceID: serviceID,
ServerID: serverID2,
Timestamp: ts,
Delay: float64(20 + i),
Successful: i%2 == 0, // 偶数成功,奇数失败
})
require.NoError(t, err)
}
// 强制刷盘确保数据可见
db.Flush()
// 查询服务历史
result, err := db.QueryServiceHistory(serviceID, Period1Day)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, serviceID, result.ServiceID)
require.Len(t, result.Servers, 2, "expected 2 servers")
// 验证服务器统计
for _, server := range result.Servers {
if server.ServerID == serverID1 {
// 服务器1全部成功
assert.Equal(t, uint64(10), server.Stats.TotalUp)
assert.Equal(t, uint64(0), server.Stats.TotalDown)
assert.Equal(t, float32(100), server.Stats.UpPercent)
} else if server.ServerID == serverID2 {
// 服务器2一半成功
assert.Equal(t, uint64(5), server.Stats.TotalUp)
assert.Equal(t, uint64(5), server.Stats.TotalDown)
assert.Equal(t, float32(50), server.Stats.UpPercent)
}
}
}
func TestTSDB_QueryServerMetrics(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
// 写入测试数据
now := time.Now()
serverID := uint64(1)
for i := 0; i < 10; i++ {
ts := now.Add(-time.Duration(i) * time.Minute)
err := db.WriteServerMetrics(&ServerMetrics{
ServerID: serverID,
Timestamp: ts,
CPU: float64(10 + i*5),
})
require.NoError(t, err)
}
// 强制刷盘确保数据可见
db.Flush()
// 查询服务器指标
result, err := db.QueryServerMetrics(serverID, MetricServerCPU, Period1Day)
require.NoError(t, err)
require.NotEmpty(t, result, "expected data points")
}
func TestTSDB_QueryEmptyResult(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
// 查询不存在的服务历史
result, err := db.QueryServiceHistory(9999, Period1Day)
require.NoError(t, err)
require.NotNil(t, result)
assert.Empty(t, result.Servers)
// 查询不存在的服务器指标
serverResult, err := db.QueryServerMetrics(9999, MetricServerCPU, Period1Day)
require.NoError(t, err)
assert.Empty(t, serverResult)
}
func TestTSDB_QueryClosedDB(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
db.Close()
// 查询已关闭的数据库应该返回错误
_, err = db.QueryServiceHistory(1, Period1Day)
assert.Error(t, err)
_, err = db.QueryServerMetrics(1, MetricServerCPU, Period1Day)
assert.Error(t, err)
}
func TestDownsample(t *testing.T) {
points := []rawDataPoint{
{timestamp: 0, value: 10, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 1000, value: 20, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 2000, value: 30, status: 0, hasDelay: true, hasStatus: true},
{timestamp: 3000, value: 40, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 4000, value: 50, status: 1, hasDelay: true, hasStatus: true},
}
result := downsample(points, 2*time.Second)
assert.Len(t, result, 3)
for i := 1; i < len(result); i++ {
assert.Greater(t, result[i].Timestamp, result[i-1].Timestamp)
}
}
func TestCalculateStats(t *testing.T) {
points := []rawDataPoint{
{timestamp: 1000, value: 10, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 2000, value: 20, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 3000, value: 30, status: 0, hasDelay: true, hasStatus: true},
{timestamp: 4000, value: 40, status: 1, hasDelay: true, hasStatus: true},
}
stats := calculateStats(points, 5*time.Minute)
assert.Equal(t, uint64(3), stats.TotalUp)
assert.Equal(t, uint64(1), stats.TotalDown)
assert.Equal(t, float32(75), stats.UpPercent)
assert.Equal(t, float64(25), stats.AvgDelay)
}
func TestCalculateStats_ZeroDelay(t *testing.T) {
points := []rawDataPoint{
{timestamp: 1000, value: 0, status: 1, hasDelay: true, hasStatus: true},
{timestamp: 2000, value: 10, status: 1, hasDelay: true, hasStatus: true},
}
stats := calculateStats(points, 5*time.Minute)
assert.Equal(t, float64(5), stats.AvgDelay)
assert.Equal(t, uint64(2), stats.TotalUp)
}
func TestCalculateStatsEmpty(t *testing.T) {
points := []rawDataPoint{}
stats := calculateStats(points, 5*time.Minute)
assert.Equal(t, uint64(0), stats.TotalUp)
assert.Equal(t, uint64(0), stats.TotalDown)
assert.Equal(t, float32(0), stats.UpPercent)
assert.Equal(t, float64(0), stats.AvgDelay)
assert.Nil(t, stats.DataPoints)
}
func TestTSDB_QueryServerMetrics_Float64Precision(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
now := time.Now()
serverID := uint64(1)
largeMemValue := uint64(17_179_869_184) // 16GB
err = db.WriteServerMetrics(&ServerMetrics{
ServerID: serverID,
Timestamp: now,
MemUsed: largeMemValue,
})
require.NoError(t, err)
db.Flush()
result, err := db.QueryServerMetrics(serverID, MetricServerMemory, Period1Day)
require.NoError(t, err)
require.NotEmpty(t, result)
// float64 可以精确表示该值,float32 会丢失精度
assert.Equal(t, float64(largeMemValue), result[0].Value)
}
func TestTSDB_QueryServiceHistoryByServerID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
now := time.Now()
serverID := uint64(1)
serviceID1 := uint64(100)
serviceID2 := uint64(200)
// 写入两个服务在同一服务器上的数据
for i := 0; i < 5; i++ {
ts := now.Add(-time.Duration(i) * time.Minute)
err := db.WriteServiceMetrics(&ServiceMetrics{
ServiceID: serviceID1,
ServerID: serverID,
Timestamp: ts,
Delay: float64(10 + i),
Successful: true,
})
require.NoError(t, err)
err = db.WriteServiceMetrics(&ServiceMetrics{
ServiceID: serviceID2,
ServerID: serverID,
Timestamp: ts,
Delay: float64(20 + i),
Successful: i%2 == 0,
})
require.NoError(t, err)
}
db.Flush()
results, err := db.QueryServiceHistoryByServerID(serverID, Period1Day)
require.NoError(t, err)
require.Len(t, results, 2, "expected 2 services")
// 验证 service1:全部成功
s1, ok := results[serviceID1]
require.True(t, ok)
assert.Equal(t, serviceID1, s1.ServiceID)
require.Len(t, s1.Servers, 1)
assert.Equal(t, serverID, s1.Servers[0].ServerID)
assert.Equal(t, uint64(5), s1.Servers[0].Stats.TotalUp)
assert.Equal(t, uint64(0), s1.Servers[0].Stats.TotalDown)
// 验证 service2:部分成功
s2, ok := results[serviceID2]
require.True(t, ok)
assert.Equal(t, serviceID2, s2.ServiceID)
assert.Equal(t, uint64(3), s2.Servers[0].Stats.TotalUp)
assert.Equal(t, uint64(2), s2.Servers[0].Stats.TotalDown)
}
func TestTSDB_QueryServiceHistoryByServerID_Empty(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
defer db.Close()
results, err := db.QueryServiceHistoryByServerID(9999, Period1Day)
require.NoError(t, err)
assert.Empty(t, results)
}
func TestTSDB_QueryServiceHistoryByServerID_ClosedDB(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tsdb_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
config := &Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 1,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := Open(config)
require.NoError(t, err)
db.Close()
_, err = db.QueryServiceHistoryByServerID(1, Period1Day)
assert.Error(t, err)
}
+301
View File
@@ -0,0 +1,301 @@
package tsdb
import (
"fmt"
"strconv"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
)
type bufferedWriter struct {
db *TSDB
buffer []storage.MetricRow
mu sync.Mutex
maxSize int
flushTicker *time.Ticker
stopCh chan struct{}
wg sync.WaitGroup
}
func newBufferedWriter(db *TSDB, maxSize int, flushInterval time.Duration) *bufferedWriter {
w := &bufferedWriter{
db: db,
buffer: make([]storage.MetricRow, 0, maxSize),
maxSize: maxSize,
flushTicker: time.NewTicker(flushInterval),
stopCh: make(chan struct{}),
}
w.wg.Add(1)
go w.flushLoop()
return w
}
func (w *bufferedWriter) flushLoop() {
defer w.wg.Done()
for {
select {
case <-w.flushTicker.C:
w.flush()
case <-w.stopCh:
w.flush()
return
}
}
}
func (w *bufferedWriter) write(rows []storage.MetricRow) {
w.mu.Lock()
w.buffer = append(w.buffer, rows...)
if len(w.buffer) >= w.maxSize {
rows := w.buffer
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
w.mu.Unlock()
w.db.storage.AddRows(rows, 64)
return
}
w.mu.Unlock()
}
func (w *bufferedWriter) flush() {
w.mu.Lock()
if len(w.buffer) == 0 {
w.mu.Unlock()
return
}
rows := w.buffer
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
w.mu.Unlock()
w.db.storage.AddRows(rows, 64)
}
func (w *bufferedWriter) stop() {
w.flushTicker.Stop()
close(w.stopCh)
w.wg.Wait()
}
// MetricType 指标类型
type MetricType string
const (
// 服务器指标
MetricServerCPU MetricType = "nezha_server_cpu"
MetricServerMemory MetricType = "nezha_server_memory"
MetricServerSwap MetricType = "nezha_server_swap"
MetricServerDisk MetricType = "nezha_server_disk"
MetricServerNetInSpeed MetricType = "nezha_server_net_in_speed"
MetricServerNetOutSpeed MetricType = "nezha_server_net_out_speed"
MetricServerNetInTransfer MetricType = "nezha_server_net_in_transfer"
MetricServerNetOutTransfer MetricType = "nezha_server_net_out_transfer"
MetricServerLoad1 MetricType = "nezha_server_load1"
MetricServerLoad5 MetricType = "nezha_server_load5"
MetricServerLoad15 MetricType = "nezha_server_load15"
MetricServerTCPConn MetricType = "nezha_server_tcp_conn"
MetricServerUDPConn MetricType = "nezha_server_udp_conn"
MetricServerProcessCount MetricType = "nezha_server_process_count"
MetricServerTemperature MetricType = "nezha_server_temperature"
MetricServerUptime MetricType = "nezha_server_uptime"
MetricServerGPU MetricType = "nezha_server_gpu"
// 服务监控指标
MetricServiceDelay MetricType = "nezha_service_delay"
MetricServiceStatus MetricType = "nezha_service_status"
)
// ServerMetrics 服务器指标数据
type ServerMetrics struct {
ServerID uint64
Timestamp time.Time
CPU float64
MemUsed uint64
SwapUsed uint64
DiskUsed uint64
NetInSpeed uint64
NetOutSpeed uint64
NetInTransfer uint64
NetOutTransfer uint64
Load1 float64
Load5 float64
Load15 float64
TCPConnCount uint64
UDPConnCount uint64
ProcessCount uint64
Temperature float64
Uptime uint64
GPU float64
}
// ServiceMetrics 服务监控指标数据
type ServiceMetrics struct {
ServiceID uint64
ServerID uint64
Timestamp time.Time
Delay float64
Successful bool
}
func (db *TSDB) WriteServerMetrics(m *ServerMetrics) error {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return fmt.Errorf("TSDB is closed")
}
ts := m.Timestamp.UnixMilli()
serverIDStr := strconv.FormatUint(m.ServerID, 10)
rows := []storage.MetricRow{
makeServerMetricRow(MetricServerCPU, serverIDStr, ts, m.CPU),
makeServerMetricRow(MetricServerMemory, serverIDStr, ts, float64(m.MemUsed)),
makeServerMetricRow(MetricServerSwap, serverIDStr, ts, float64(m.SwapUsed)),
makeServerMetricRow(MetricServerDisk, serverIDStr, ts, float64(m.DiskUsed)),
makeServerMetricRow(MetricServerNetInSpeed, serverIDStr, ts, float64(m.NetInSpeed)),
makeServerMetricRow(MetricServerNetOutSpeed, serverIDStr, ts, float64(m.NetOutSpeed)),
makeServerMetricRow(MetricServerNetInTransfer, serverIDStr, ts, float64(m.NetInTransfer)),
makeServerMetricRow(MetricServerNetOutTransfer, serverIDStr, ts, float64(m.NetOutTransfer)),
makeServerMetricRow(MetricServerLoad1, serverIDStr, ts, m.Load1),
makeServerMetricRow(MetricServerLoad5, serverIDStr, ts, m.Load5),
makeServerMetricRow(MetricServerLoad15, serverIDStr, ts, m.Load15),
makeServerMetricRow(MetricServerTCPConn, serverIDStr, ts, float64(m.TCPConnCount)),
makeServerMetricRow(MetricServerUDPConn, serverIDStr, ts, float64(m.UDPConnCount)),
makeServerMetricRow(MetricServerProcessCount, serverIDStr, ts, float64(m.ProcessCount)),
makeServerMetricRow(MetricServerTemperature, serverIDStr, ts, m.Temperature),
makeServerMetricRow(MetricServerUptime, serverIDStr, ts, float64(m.Uptime)),
makeServerMetricRow(MetricServerGPU, serverIDStr, ts, m.GPU),
}
if db.writer != nil {
db.writer.write(rows)
} else {
db.storage.AddRows(rows, 64)
}
return nil
}
func (db *TSDB) WriteServiceMetrics(m *ServiceMetrics) error {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return fmt.Errorf("TSDB is closed")
}
ts := m.Timestamp.UnixMilli()
serviceIDStr := strconv.FormatUint(m.ServiceID, 10)
serverIDStr := strconv.FormatUint(m.ServerID, 10)
var status float64
if m.Successful {
status = 1
}
rows := []storage.MetricRow{
makeServiceMetricRow(MetricServiceDelay, serviceIDStr, serverIDStr, ts, m.Delay),
makeServiceMetricRow(MetricServiceStatus, serviceIDStr, serverIDStr, ts, status),
}
if db.writer != nil {
db.writer.write(rows)
} else {
db.storage.AddRows(rows, 64)
}
return nil
}
func makeServerMetricRow(metric MetricType, serverID string, timestamp int64, value float64) storage.MetricRow {
labels := []prompb.Label{
{Name: "__name__", Value: string(metric)},
{Name: "server_id", Value: serverID},
}
return storage.MetricRow{
MetricNameRaw: storage.MarshalMetricNameRaw(nil, labels),
Timestamp: timestamp,
Value: value,
}
}
func makeServiceMetricRow(metric MetricType, serviceID, serverID string, timestamp int64, value float64) storage.MetricRow {
labels := []prompb.Label{
{Name: "__name__", Value: string(metric)},
{Name: "service_id", Value: serviceID},
{Name: "server_id", Value: serverID},
}
return storage.MetricRow{
MetricNameRaw: storage.MarshalMetricNameRaw(nil, labels),
Timestamp: timestamp,
Value: value,
}
}
func (db *TSDB) WriteBatchServerMetrics(metrics []*ServerMetrics) error {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return fmt.Errorf("TSDB is closed")
}
rows := make([]storage.MetricRow, 0, len(metrics)*17)
for _, m := range metrics {
ts := m.Timestamp.UnixMilli()
serverIDStr := strconv.FormatUint(m.ServerID, 10)
rows = append(rows,
makeServerMetricRow(MetricServerCPU, serverIDStr, ts, m.CPU),
makeServerMetricRow(MetricServerMemory, serverIDStr, ts, float64(m.MemUsed)),
makeServerMetricRow(MetricServerSwap, serverIDStr, ts, float64(m.SwapUsed)),
makeServerMetricRow(MetricServerDisk, serverIDStr, ts, float64(m.DiskUsed)),
makeServerMetricRow(MetricServerNetInSpeed, serverIDStr, ts, float64(m.NetInSpeed)),
makeServerMetricRow(MetricServerNetOutSpeed, serverIDStr, ts, float64(m.NetOutSpeed)),
makeServerMetricRow(MetricServerNetInTransfer, serverIDStr, ts, float64(m.NetInTransfer)),
makeServerMetricRow(MetricServerNetOutTransfer, serverIDStr, ts, float64(m.NetOutTransfer)),
makeServerMetricRow(MetricServerLoad1, serverIDStr, ts, m.Load1),
makeServerMetricRow(MetricServerLoad5, serverIDStr, ts, m.Load5),
makeServerMetricRow(MetricServerLoad15, serverIDStr, ts, m.Load15),
makeServerMetricRow(MetricServerTCPConn, serverIDStr, ts, float64(m.TCPConnCount)),
makeServerMetricRow(MetricServerUDPConn, serverIDStr, ts, float64(m.UDPConnCount)),
makeServerMetricRow(MetricServerProcessCount, serverIDStr, ts, float64(m.ProcessCount)),
makeServerMetricRow(MetricServerTemperature, serverIDStr, ts, m.Temperature),
makeServerMetricRow(MetricServerUptime, serverIDStr, ts, float64(m.Uptime)),
makeServerMetricRow(MetricServerGPU, serverIDStr, ts, m.GPU),
)
}
if db.writer != nil {
db.writer.write(rows)
} else {
db.storage.AddRows(rows, 64)
}
return nil
}
func (db *TSDB) WriteBatchServiceMetrics(metrics []*ServiceMetrics) error {
db.mu.RLock()
defer db.mu.RUnlock()
if db.closed {
return fmt.Errorf("TSDB is closed")
}
rows := make([]storage.MetricRow, 0, len(metrics)*2)
for _, m := range metrics {
ts := m.Timestamp.UnixMilli()
serviceIDStr := strconv.FormatUint(m.ServiceID, 10)
serverIDStr := strconv.FormatUint(m.ServerID, 10)
var status float64
if m.Successful {
status = 1
}
rows = append(rows,
makeServiceMetricRow(MetricServiceDelay, serviceIDStr, serverIDStr, ts, m.Delay),
makeServiceMetricRow(MetricServiceStatus, serviceIDStr, serverIDStr, ts, status),
)
}
if db.writer != nil {
db.writer.write(rows)
} else {
db.storage.AddRows(rows, 64)
}
return nil
}
+39
View File
@@ -12,6 +12,7 @@ import (
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
geoipx "github.com/nezhahq/nezha/pkg/geoip" geoipx "github.com/nezhahq/nezha/pkg/geoip"
"github.com/nezhahq/nezha/pkg/grpcx" "github.com/nezhahq/nezha/pkg/grpcx"
"github.com/nezhahq/nezha/pkg/tsdb"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
@@ -114,6 +115,44 @@ func (s *NezhaHandler) ReportSystemState(stream pb.NezhaService_ReportSystemStat
server.LastActive = time.Now() server.LastActive = time.Now()
server.State = &innerState server.State = &innerState
if singleton.TSDBEnabled() {
maxTemp := 0.0
for _, t := range innerState.Temperatures {
if t.Temperature > maxTemp {
maxTemp = t.Temperature
}
}
maxGPU := 0.0
for _, g := range innerState.GPU {
if g > maxGPU {
maxGPU = g
}
}
if err := singleton.TSDBShared.WriteServerMetrics(&tsdb.ServerMetrics{
ServerID: clientID,
Timestamp: time.Now(),
CPU: innerState.CPU,
MemUsed: innerState.MemUsed,
SwapUsed: innerState.SwapUsed,
DiskUsed: innerState.DiskUsed,
NetInSpeed: innerState.NetInSpeed,
NetOutSpeed: innerState.NetOutSpeed,
NetInTransfer: innerState.NetInTransfer,
NetOutTransfer: innerState.NetOutTransfer,
Load1: innerState.Load1,
Load5: innerState.Load5,
Load15: innerState.Load15,
TCPConnCount: innerState.TcpConnCount,
UDPConnCount: innerState.UdpConnCount,
ProcessCount: innerState.ProcessCount,
Temperature: maxTemp,
Uptime: innerState.Uptime,
GPU: maxGPU,
}); err != nil {
log.Printf("NEZHA>> Failed to write server metrics to TSDB: %v", err)
}
}
// 应对 dashboard / agent 重启的情况,如果从未记录过,先打点,等到小时时间点时入库 // 应对 dashboard / agent 重启的情况,如果从未记录过,先打点,等到小时时间点时入库
if server.PrevTransferInSnapshot == 0 || server.PrevTransferOutSnapshot == 0 { if server.PrevTransferInSnapshot == 0 || server.PrevTransferOutSnapshot == 0 {
server.PrevTransferInSnapshot = state.NetInTransfer server.PrevTransferInSnapshot = state.NetInTransfer
+2 -2
View File
@@ -2,14 +2,14 @@
name: "OfficialAdmin" name: "OfficialAdmin"
repository: "https://github.com/nezhahq/admin-frontend" repository: "https://github.com/nezhahq/admin-frontend"
author: "nezhahq" author: "nezhahq"
version: "v1.14.7" version: "v2.0.3"
is_admin: true is_admin: true
is_official: true is_official: true
- path: "user-dist" - path: "user-dist"
name: "Official" name: "Official"
repository: "https://github.com/hamster1963/nezha-dash-v1" repository: "https://github.com/hamster1963/nezha-dash-v1"
author: "hamster1963" author: "hamster1963"
version: "v1.33.0" version: "v2.0.0"
is_official: true is_official: true
- path: "nazhua-dist" - path: "nazhua-dist"
name: "Nazhua" name: "Nazhua"
+175 -67
View File
@@ -16,6 +16,7 @@ import (
"golang.org/x/exp/constraints" "golang.org/x/exp/constraints"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/tsdb"
"github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/pkg/utils"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
) )
@@ -39,7 +40,7 @@ type ReportData struct {
type _TodayStatsOfService struct { type _TodayStatsOfService struct {
Up uint64 // 今日在线计数 Up uint64 // 今日在线计数
Down uint64 // 今日离线计数 Down uint64 // 今日离线计数
Delay float32 // 今日平均延迟 Delay float64 // 今日平均延迟
} }
type serviceResponseData = _TodayStatsOfService type serviceResponseData = _TodayStatsOfService
@@ -51,8 +52,9 @@ type serviceTaskStatus struct {
} }
type pingStore struct { type pingStore struct {
count int count int
ping float32 ping float64
successCount int
} }
/* /*
@@ -108,23 +110,7 @@ func NewServiceSentinel(serviceSentinelDispatchBus chan<- *model.Service) (*Serv
year, month, day := time.Now().Date() year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, Loc) today := time.Date(year, month, day, 0, 0, 0, 0, Loc)
ss.loadTodayStats(today)
var mhs []model.ServiceHistory
// 加载当日记录
DB.Where("created_at >= ? AND server_id = 0", today).Find(&mhs)
totalDelay := make(map[uint64]float32)
totalDelayCount := make(map[uint64]float32)
for _, mh := range mhs {
totalDelay[mh.ServiceID] += mh.AvgDelay
totalDelayCount[mh.ServiceID]++
ss.serviceStatusToday[mh.ServiceID].Up += mh.Up
ss.monthlyStatus[mh.ServiceID].TotalUp += mh.Up
ss.serviceStatusToday[mh.ServiceID].Down += mh.Down
ss.monthlyStatus[mh.ServiceID].TotalDown += mh.Down
}
for id, delay := range totalDelay {
ss.serviceStatusToday[id].Delay = delay / float32(totalDelayCount[id])
}
// 启动服务监控器 // 启动服务监控器
go ss.worker() go ss.worker()
@@ -135,6 +121,12 @@ func NewServiceSentinel(serviceSentinelDispatchBus chan<- *model.Service) (*Serv
return nil, err return nil, err
} }
// 每周日凌晨 4:00 执行系统存储维护
_, err = CronShared.AddFunc("0 0 4 * * 0", PerformMaintenance)
if err != nil {
log.Printf("NEZHA>> Warning: failed to schedule maintenance task: %v", err)
}
return ss, nil return ss, nil
} }
@@ -171,6 +163,16 @@ func (ss *ServiceSentinel) Dispatch(r ReportData) {
ss.serviceReportChannel <- r ss.serviceReportChannel <- r
} }
// sortServices 按 DisplayIndex 降序、ID 升序排列服务列表
func sortServices(services []*model.Service) {
slices.SortFunc(services, func(a, b *model.Service) int {
if a.DisplayIndex != b.DisplayIndex {
return cmp.Compare(b.DisplayIndex, a.DisplayIndex)
}
return cmp.Compare(a.ID, b.ID)
})
}
func (ss *ServiceSentinel) UpdateServiceList() { func (ss *ServiceSentinel) UpdateServiceList() {
ss.servicesLock.RLock() ss.servicesLock.RLock()
defer ss.servicesLock.RUnlock() defer ss.servicesLock.RUnlock()
@@ -179,9 +181,7 @@ func (ss *ServiceSentinel) UpdateServiceList() {
defer ss.serviceListLock.Unlock() defer ss.serviceListLock.Unlock()
ss.serviceList = utils.MapValuesToSlice(ss.services) ss.serviceList = utils.MapValuesToSlice(ss.services)
slices.SortFunc(ss.serviceList, func(a, b *model.Service) int { sortServices(ss.serviceList)
return cmp.Compare(a.ID, b.ID)
})
} }
// loadServiceHistory 加载服务监控器的历史状态信息 // loadServiceHistory 加载服务监控器的历史状态信息
@@ -207,6 +207,7 @@ func (ss *ServiceSentinel) loadServiceHistory() error {
ss.serviceStatusToday[service.ID] = &_TodayStatsOfService{} ss.serviceStatusToday[service.ID] = &_TodayStatsOfService{}
} }
ss.serviceList = services ss.serviceList = services
sortServices(ss.serviceList)
year, month, day := time.Now().Date() year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, Loc) today := time.Date(year, month, day, 0, 0, 0, 0, Loc)
@@ -215,33 +216,111 @@ func (ss *ServiceSentinel) loadServiceHistory() error {
ss.monthlyStatus[service.ID] = &serviceResponseItem{ ss.monthlyStatus[service.ID] = &serviceResponseItem{
service: service, service: service,
ServiceResponseItem: model.ServiceResponseItem{ ServiceResponseItem: model.ServiceResponseItem{
Delay: &[30]float32{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Delay: &[30]float64{},
Up: &[30]uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Up: &[30]uint64{},
Down: &[30]uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Down: &[30]uint64{},
}, },
} }
} }
// 加载服务监控历史记录 if TSDBEnabled() {
var mhs []model.ServiceHistory ss.loadMonthlyStatusFromTSDB(services, today)
DB.Where("created_at > ? AND created_at < ? AND server_id = 0", today.AddDate(0, 0, -29), today).Find(&mhs) } else {
var delayCount = make(map[int]int) ss.loadMonthlyStatusFromDB(today)
for _, mh := range mhs {
dayIndex := 28 - (int(today.Sub(mh.CreatedAt).Hours()) / 24)
if dayIndex < 0 {
continue
}
ss.monthlyStatus[mh.ServiceID].Delay[dayIndex] = (ss.monthlyStatus[mh.ServiceID].Delay[dayIndex]*float32(delayCount[dayIndex]) + mh.AvgDelay) / float32(delayCount[dayIndex]+1)
delayCount[dayIndex]++
ss.monthlyStatus[mh.ServiceID].Up[dayIndex] += mh.Up
ss.monthlyStatus[mh.ServiceID].TotalUp += mh.Up
ss.monthlyStatus[mh.ServiceID].Down[dayIndex] += mh.Down
ss.monthlyStatus[mh.ServiceID].TotalDown += mh.Down
} }
return nil return nil
} }
func (ss *ServiceSentinel) loadMonthlyStatusFromTSDB(services []*model.Service, today time.Time) {
for _, service := range services {
dailyStats, err := TSDBShared.QueryServiceDailyStats(service.ID, today, 30)
if err != nil {
log.Printf("NEZHA>> Failed to load TSDB history for service %d: %v", service.ID, err)
continue
}
ms := ss.monthlyStatus[service.ID]
for i := 0; i < 29; i++ {
ms.Up[i] = dailyStats[i].Up
ms.TotalUp += dailyStats[i].Up
ms.Down[i] = dailyStats[i].Down
ms.TotalDown += dailyStats[i].Down
ms.Delay[i] = dailyStats[i].Delay
}
}
}
func (ss *ServiceSentinel) loadMonthlyStatusFromDB(today time.Time) {
var mhs []model.ServiceHistory
DB.Where("created_at > ? AND created_at < ? AND server_id = 0", today.AddDate(0, 0, -29), today).Find(&mhs)
delayCount := make(map[uint64]map[int]int)
for _, mh := range mhs {
dayIndex := 28 - int(today.Sub(mh.CreatedAt).Hours())/24
if dayIndex < 0 {
continue
}
ms := ss.monthlyStatus[mh.ServiceID]
if ms == nil {
continue
}
if delayCount[mh.ServiceID] == nil {
delayCount[mh.ServiceID] = make(map[int]int)
}
ms.Delay[dayIndex] = (ms.Delay[dayIndex]*float64(delayCount[mh.ServiceID][dayIndex]) + mh.AvgDelay) / float64(delayCount[mh.ServiceID][dayIndex]+1)
delayCount[mh.ServiceID][dayIndex]++
ms.Up[dayIndex] += mh.Up
ms.TotalUp += mh.Up
ms.Down[dayIndex] += mh.Down
ms.TotalDown += mh.Down
}
}
func (ss *ServiceSentinel) loadTodayStats(today time.Time) {
if TSDBEnabled() {
for serviceID, ms := range ss.monthlyStatus {
result, err := TSDBShared.QueryServiceHistory(serviceID, tsdb.Period1Day)
if err != nil {
log.Printf("NEZHA>> Failed to load TSDB today stats for service %d: %v", serviceID, err)
continue
}
var totalUp, totalDown uint64
var totalDelay float64
var delayCount int
for _, serverStats := range result.Servers {
totalUp += serverStats.Stats.TotalUp
totalDown += serverStats.Stats.TotalDown
if serverStats.Stats.AvgDelay > 0 {
totalDelay += serverStats.Stats.AvgDelay
delayCount++
}
}
ss.serviceStatusToday[serviceID].Up = totalUp
ss.serviceStatusToday[serviceID].Down = totalDown
if delayCount > 0 {
ss.serviceStatusToday[serviceID].Delay = totalDelay / float64(delayCount)
}
ms.TotalUp += totalUp
ms.TotalDown += totalDown
}
} else {
var mhs []model.ServiceHistory
DB.Where("created_at >= ? AND server_id = 0", today).Find(&mhs)
totalDelay := make(map[uint64]float64)
totalDelayCount := make(map[uint64]int)
for _, mh := range mhs {
ss.serviceStatusToday[mh.ServiceID].Up += mh.Up
ss.monthlyStatus[mh.ServiceID].TotalUp += mh.Up
ss.serviceStatusToday[mh.ServiceID].Down += mh.Down
ss.monthlyStatus[mh.ServiceID].TotalDown += mh.Down
totalDelay[mh.ServiceID] += mh.AvgDelay
totalDelayCount[mh.ServiceID]++
}
for id, delay := range totalDelay {
ss.serviceStatusToday[id].Delay = delay / float64(totalDelayCount[id])
}
}
}
func (ss *ServiceSentinel) Update(m *model.Service) error { func (ss *ServiceSentinel) Update(m *model.Service) error {
ss.serviceResponseDataStoreLock.Lock() ss.serviceResponseDataStoreLock.Lock()
defer ss.serviceResponseDataStoreLock.Unlock() defer ss.serviceResponseDataStoreLock.Unlock()
@@ -266,9 +345,9 @@ func (ss *ServiceSentinel) Update(m *model.Service) error {
ss.monthlyStatus[m.ID] = &serviceResponseItem{ ss.monthlyStatus[m.ID] = &serviceResponseItem{
service: m, service: m,
ServiceResponseItem: model.ServiceResponseItem{ ServiceResponseItem: model.ServiceResponseItem{
Delay: &[30]float32{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Delay: &[30]float64{},
Up: &[30]uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Up: &[30]uint64{},
Down: &[30]uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, Down: &[30]uint64{},
}, },
} }
if ss.serviceCurrentStatusData[m.ID] == nil { if ss.serviceCurrentStatusData[m.ID] == nil {
@@ -406,6 +485,7 @@ func (ss *ServiceSentinel) worker() {
mh := r.Data mh := r.Data
if mh.Type == model.TaskTypeTCPPing || mh.Type == model.TaskTypeICMPPing { if mh.Type == model.TaskTypeTCPPing || mh.Type == model.TaskTypeICMPPing {
// TCP/ICMP Ping 使用平均值计算后再写入
serviceTcpMap, ok := ss.serviceResponsePing[mh.GetId()] serviceTcpMap, ok := ss.serviceResponsePing[mh.GetId()]
if !ok { if !ok {
serviceTcpMap = make(map[uint64]*pingStore) serviceTcpMap = make(map[uint64]*pingStore)
@@ -416,28 +496,56 @@ func (ss *ServiceSentinel) worker() {
ts = &pingStore{} ts = &pingStore{}
} }
ts.count++ ts.count++
ts.ping = (ts.ping*float32(ts.count-1) + mh.Delay) / float32(ts.count) ts.ping = (ts.ping*float64(ts.count-1) + float64(mh.Delay)) / float64(ts.count)
if mh.Successful {
ts.successCount++
}
if ts.count == Conf.AvgPingCount { if ts.count == Conf.AvgPingCount {
if err := DB.Create(&model.ServiceHistory{ if TSDBEnabled() {
ServiceID: mh.GetId(), if err := TSDBShared.WriteServiceMetrics(&tsdb.ServiceMetrics{
AvgDelay: ts.ping, ServiceID: mh.GetId(),
Data: mh.Data, ServerID: r.Reporter,
ServerID: r.Reporter, Timestamp: time.Now(),
}).Error; err != nil { Delay: ts.ping,
log.Printf("NEZHA>> Failed to save service monitor metrics: %v", err) Successful: ts.successCount*2 >= ts.count,
}); err != nil {
log.Printf("NEZHA>> Failed to save service monitor metrics to TSDB: %v", err)
}
} else {
if err := DB.Create(&model.ServiceHistory{
ServiceID: mh.GetId(),
AvgDelay: ts.ping,
Data: mh.Data,
ServerID: r.Reporter,
}).Error; err != nil {
log.Printf("NEZHA>> Failed to save service monitor metrics: %v", err)
}
} }
ts.count = 0 ts.count = 0
ts.ping = mh.Delay ts.ping = 0
ts.successCount = 0
} }
serviceTcpMap[r.Reporter] = ts serviceTcpMap[r.Reporter] = ts
} else {
if TSDBEnabled() {
if err := TSDBShared.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: mh.GetId(),
ServerID: r.Reporter,
Timestamp: time.Now(),
Delay: float64(mh.Delay),
Successful: mh.Successful,
}); err != nil {
log.Printf("NEZHA>> Failed to save service monitor metrics to TSDB: %v", err)
}
}
} }
ss.serviceResponseDataStoreLock.Lock() ss.serviceResponseDataStoreLock.Lock()
// 写入当天状态 // 写入当天状态
if mh.Successful { if mh.Successful {
ss.serviceStatusToday[mh.GetId()].Delay = (ss.serviceStatusToday[mh. ss.serviceStatusToday[mh.GetId()].Delay = (ss.serviceStatusToday[mh.
GetId()].Delay*float32(ss.serviceStatusToday[mh.GetId()].Up) + GetId()].Delay*float64(ss.serviceStatusToday[mh.GetId()].Up) +
mh.Delay) / float32(ss.serviceStatusToday[mh.GetId()].Up+1) float64(mh.Delay)) / float64(ss.serviceStatusToday[mh.GetId()].Up+1)
ss.serviceStatusToday[mh.GetId()].Up++ ss.serviceStatusToday[mh.GetId()].Up++
} else { } else {
ss.serviceStatusToday[mh.GetId()].Down++ ss.serviceStatusToday[mh.GetId()].Down++
@@ -463,7 +571,7 @@ func (ss *ServiceSentinel) worker() {
rd := ss.serviceResponseDataStore[mh.GetId()] rd := ss.serviceResponseDataStore[mh.GetId()]
if cs.Successful { if cs.Successful {
rd.Up++ rd.Up++
rd.Delay = (rd.Delay*float32(rd.Up-1) + cs.Delay) / float32(rd.Up) rd.Delay = (rd.Delay*float64(rd.Up-1) + float64(cs.Delay)) / float64(rd.Up)
} else { } else {
rd.Down++ rd.Down++
} }
@@ -482,20 +590,20 @@ func (ss *ServiceSentinel) worker() {
stateCode = GetStatusCode(upPercent) stateCode = GetStatusCode(upPercent)
} }
// 数据持久化
if len(ss.serviceCurrentStatusData[mh.GetId()].result) == _CurrentStatusSize { if len(ss.serviceCurrentStatusData[mh.GetId()].result) == _CurrentStatusSize {
ss.serviceCurrentStatusData[mh.GetId()].t = currentTime ss.serviceCurrentStatusData[mh.GetId()].t = currentTime
rd := ss.serviceResponseDataStore[mh.GetId()] if !TSDBEnabled() {
if err := DB.Create(&model.ServiceHistory{ rd := ss.serviceResponseDataStore[mh.GetId()]
ServiceID: mh.GetId(), if err := DB.Create(&model.ServiceHistory{
AvgDelay: rd.Delay, ServiceID: mh.GetId(),
Data: mh.Data, AvgDelay: rd.Delay,
Up: rd.Up, Data: mh.Data,
Down: rd.Down, Up: rd.Up,
}).Error; err != nil { Down: rd.Down,
log.Printf("NEZHA>> Failed to save service monitor metrics: %v", err) }).Error; err != nil {
log.Printf("NEZHA>> Failed to save service monitor metrics: %v", err)
}
} }
ss.serviceCurrentStatusData[mh.GetId()].result = ss.serviceCurrentStatusData[mh.GetId()].result[:0] ss.serviceCurrentStatusData[mh.GetId()].result = ss.serviceCurrentStatusData[mh.GetId()].result[:0]
} }
+336
View File
@@ -0,0 +1,336 @@
package singleton
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/tsdb"
)
func newTestSentinel(serviceIDs []uint64) *ServiceSentinel {
ss := &ServiceSentinel{
serviceStatusToday: make(map[uint64]*_TodayStatsOfService),
monthlyStatus: make(map[uint64]*serviceResponseItem),
}
for _, id := range serviceIDs {
ss.serviceStatusToday[id] = &_TodayStatsOfService{}
ss.monthlyStatus[id] = &serviceResponseItem{
service: &model.Service{Common: model.Common{ID: id}},
ServiceResponseItem: model.ServiceResponseItem{
Delay: &[30]float64{},
Up: &[30]uint64{},
Down: &[30]uint64{},
},
}
}
return ss
}
func setupTestDB(t *testing.T) func() {
t.Helper()
var err error
DB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, DB.AutoMigrate(model.ServiceHistory{}))
return func() { DB = nil }
}
func setupTestTSDB(t *testing.T) (*tsdb.TSDB, func()) {
t.Helper()
tempDir, err := os.MkdirTemp("", "tsdb_sentinel_test")
require.NoError(t, err)
config := &tsdb.Config{
DataPath: filepath.Join(tempDir, "tsdb"),
RetentionDays: 30,
MinFreeDiskSpaceGB: 1,
DedupInterval: time.Second,
}
db, err := tsdb.Open(config)
require.NoError(t, err)
TSDBShared = db
return db, func() {
db.Close()
TSDBShared = nil
os.RemoveAll(tempDir)
}
}
func TestLoadMonthlyStatusFromDB(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
ss := newTestSentinel([]uint64{serviceID})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 10.0,
Up: 5,
Down: 1,
CreatedAt: today.Add(-25 * time.Hour),
})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 20.0,
Up: 3,
Down: 2,
CreatedAt: today.Add(-25 * time.Hour),
})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 30.0,
Up: 10,
Down: 0,
CreatedAt: today.Add(-49 * time.Hour),
})
ss.loadMonthlyStatusFromDB(today)
ms := ss.monthlyStatus[serviceID]
// day -1: index 27, two records with AvgDelay 10 and 20
assert.InDelta(t, 15.0, ms.Delay[27], 0.01)
assert.Equal(t, uint64(8), ms.Up[27])
assert.Equal(t, uint64(3), ms.Down[27])
// day -2: index 26
assert.InDelta(t, 30.0, ms.Delay[26], 0.01)
assert.Equal(t, uint64(10), ms.Up[26])
assert.Equal(t, uint64(0), ms.Down[26])
// totals
assert.Equal(t, uint64(18), ms.TotalUp)
assert.Equal(t, uint64(3), ms.TotalDown)
// today (index 29) should be untouched
assert.Equal(t, float64(0), ms.Delay[29])
assert.Equal(t, uint64(0), ms.Up[29])
}
func TestLoadMonthlyStatusFromDB_IgnoresToday(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
ss := newTestSentinel([]uint64{serviceID})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 50.0,
Up: 100,
Down: 5,
CreatedAt: today.Add(2 * time.Hour),
})
ss.loadMonthlyStatusFromDB(today)
ms := ss.monthlyStatus[serviceID]
assert.Equal(t, uint64(0), ms.TotalUp)
assert.Equal(t, uint64(0), ms.TotalDown)
}
func TestLoadMonthlyStatusFromDB_UnknownServiceIgnored(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
ss := newTestSentinel([]uint64{1})
DB.Create(&model.ServiceHistory{
ServiceID: 999,
ServerID: 0,
AvgDelay: 10.0,
Up: 5,
Down: 1,
CreatedAt: today.Add(-25 * time.Hour),
})
ss.loadMonthlyStatusFromDB(today)
ms := ss.monthlyStatus[uint64(1)]
assert.Equal(t, uint64(0), ms.TotalUp)
}
func TestLoadTodayStatsFromDB(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
ss := newTestSentinel([]uint64{serviceID})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 10.0,
Up: 5,
Down: 1,
CreatedAt: today.Add(1 * time.Hour),
})
DB.Create(&model.ServiceHistory{
ServiceID: serviceID,
ServerID: 0,
AvgDelay: 30.0,
Up: 3,
Down: 2,
CreatedAt: today.Add(2 * time.Hour),
})
ss.loadTodayStats(today)
st := ss.serviceStatusToday[serviceID]
assert.Equal(t, uint64(8), st.Up)
assert.Equal(t, uint64(3), st.Down)
assert.InDelta(t, 20.0, st.Delay, 0.01)
ms := ss.monthlyStatus[serviceID]
assert.Equal(t, uint64(8), ms.TotalUp)
assert.Equal(t, uint64(3), ms.TotalDown)
}
func TestLoadMonthlyStatusFromTSDB(t *testing.T) {
db, cleanup := setupTestTSDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
services := []*model.Service{{Common: model.Common{ID: serviceID}}}
ss := newTestSentinel([]uint64{serviceID})
yesterday := today.Add(-25 * time.Hour)
for i := 0; i < 5; i++ {
ts := yesterday.Add(time.Duration(i) * time.Minute)
require.NoError(t, db.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: serviceID,
ServerID: 1,
Timestamp: ts,
Delay: float64(10 + i),
Successful: true,
}))
}
for i := 0; i < 3; i++ {
require.NoError(t, db.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: serviceID,
ServerID: 1,
Timestamp: yesterday.Add(time.Duration(i+10) * time.Minute),
Delay: float64(20 + i),
Successful: false,
}))
}
db.Flush()
ss.loadMonthlyStatusFromTSDB(services, today)
ms := ss.monthlyStatus[serviceID]
// day -1: dayIndex 28
assert.Equal(t, uint64(5), ms.Up[28])
assert.Equal(t, uint64(3), ms.Down[28])
assert.Equal(t, uint64(5), ms.TotalUp)
assert.Equal(t, uint64(3), ms.TotalDown)
assert.Greater(t, ms.Delay[28], float64(0))
// today (index 29) should be untouched
assert.Equal(t, uint64(0), ms.Up[29])
}
func TestLoadTodayStatsFromTSDB(t *testing.T) {
db, cleanup := setupTestTSDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
ss := newTestSentinel([]uint64{serviceID})
now := time.Now()
for i := 0; i < 4; i++ {
ts := now.Add(-time.Duration(i) * time.Minute)
require.NoError(t, db.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: serviceID,
ServerID: 1,
Timestamp: ts,
Delay: float64(10 + i),
Successful: true,
}))
}
for i := 0; i < 2; i++ {
ts := now.Add(-time.Duration(i+10) * time.Minute)
require.NoError(t, db.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: serviceID,
ServerID: 1,
Timestamp: ts,
Delay: 0,
Successful: false,
}))
}
db.Flush()
ss.loadTodayStats(today)
st := ss.serviceStatusToday[serviceID]
assert.Greater(t, st.Up, uint64(0))
assert.Greater(t, st.Down, uint64(0))
ms := ss.monthlyStatus[serviceID]
assert.Equal(t, st.Up, ms.TotalUp)
assert.Equal(t, st.Down, ms.TotalDown)
}
func TestLoadMonthlyStatusFromTSDB_NoDoubleCountToday(t *testing.T) {
db, cleanup := setupTestTSDB(t)
defer cleanup()
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
serviceID := uint64(1)
services := []*model.Service{{Common: model.Common{ID: serviceID}}}
ss := newTestSentinel([]uint64{serviceID})
now := time.Now()
for i := 0; i < 5; i++ {
require.NoError(t, db.WriteServiceMetrics(&tsdb.ServiceMetrics{
ServiceID: serviceID,
ServerID: 1,
Timestamp: now.Add(-time.Duration(i) * time.Minute),
Delay: 10.0,
Successful: true,
}))
}
db.Flush()
ss.loadMonthlyStatusFromTSDB(services, today)
totalAfterMonthly := ss.monthlyStatus[serviceID].TotalUp
ss.loadTodayStats(today)
totalAfterToday := ss.monthlyStatus[serviceID].TotalUp
assert.Equal(t, totalAfterMonthly+ss.serviceStatusToday[serviceID].Up, totalAfterToday)
}
+27 -9
View File
@@ -87,12 +87,13 @@ func InitDBFromPath(path string) error {
} }
err = DB.AutoMigrate(model.Server{}, model.User{}, model.ServerGroup{}, model.NotificationGroup{}, err = DB.AutoMigrate(model.Server{}, model.User{}, model.ServerGroup{}, model.NotificationGroup{},
model.Notification{}, model.AlertRule{}, model.Service{}, model.NotificationGroupNotification{}, model.Notification{}, model.AlertRule{}, model.Service{}, model.NotificationGroupNotification{},
model.ServiceHistory{}, model.Cron{}, model.Transfer{}, model.ServerGroupServer{}, model.Cron{}, model.Transfer{}, model.ServerGroupServer{},
model.NAT{}, model.DDNSProfile{}, model.NotificationGroupNotification{}, model.NAT{}, model.DDNSProfile{}, model.NotificationGroupNotification{},
model.WAF{}, model.Oauth2Bind{}) model.WAF{}, model.Oauth2Bind{})
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@@ -130,14 +131,9 @@ func RecordTransferHourlyUsage(servers ...*model.Server) {
log.Printf("NEZHA>> Saved traffic metrics to database. Affected %d row(s), Error: %v", len(txs), DB.Create(txs).Error) log.Printf("NEZHA>> Saved traffic metrics to database. Affected %d row(s), Error: %v", len(txs), DB.Create(txs).Error)
} }
// CleanServiceHistory 清理无效或过时的 监控记录 和 流量记录 // CleanMonitorHistory 清理流量记录TSDB 有自己的保留策略)
func CleanServiceHistory() { func CleanMonitorHistory() {
// 清理已被删除的服务器的监控记录与流量记录 // 清理已被删除的服务器的流量记录
DB.Unscoped().Delete(&model.ServiceHistory{}, "created_at < ? OR service_id NOT IN (SELECT `id` FROM services)", time.Now().AddDate(0, 0, -30))
// 由于网络监控记录的数据较多,并且前端仅使用了 1 天的数据
// 考虑到 sqlite 数据量问题,仅保留一天数据,
// server_id = 0 的数据会用于/service页面的可用性展示
DB.Unscoped().Delete(&model.ServiceHistory{}, "(created_at < ? AND server_id != 0) OR service_id NOT IN (SELECT `id` FROM services)", time.Now().AddDate(0, 0, -1))
DB.Unscoped().Delete(&model.Transfer{}, "server_id NOT IN (SELECT `id` FROM servers)") DB.Unscoped().Delete(&model.Transfer{}, "server_id NOT IN (SELECT `id` FROM servers)")
// 计算可清理流量记录的时长 // 计算可清理流量记录的时长
var allServerKeep time.Time var allServerKeep time.Time
@@ -179,6 +175,28 @@ func CleanServiceHistory() {
} }
} }
// PerformMaintenance 执行系统维护(SQLite VACUUM 和 TSDB 维护)
func PerformMaintenance() {
log.Println("NEZHA>> Starting system maintenance...")
// 1. SQLite 维护
if DB != nil {
log.Println("NEZHA>> SQLite: Starting VACUUM...")
if err := DB.Exec("VACUUM").Error; err != nil {
log.Printf("NEZHA>> SQLite: VACUUM failed: %v", err)
} else {
log.Println("NEZHA>> SQLite: VACUUM completed")
}
}
// 2. TSDB 维护
if TSDBEnabled() {
TSDBShared.Maintenance()
}
log.Println("NEZHA>> System maintenance completed")
}
// IPDesensitize 根据设置选择是否对IP进行打码处理 返回处理后的IP(关闭打码则返回原IP) // IPDesensitize 根据设置选择是否对IP进行打码处理 返回处理后的IP(关闭打码则返回原IP)
func IPDesensitize(ip string) string { func IPDesensitize(ip string) string {
if Conf.EnablePlainIPInNotification { if Conf.EnablePlainIPInNotification {
+73
View File
@@ -0,0 +1,73 @@
package singleton
import (
"log"
"time"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/tsdb"
)
var TSDBShared *tsdb.TSDB
func InitTSDB() error {
config := &tsdb.Config{
RetentionDays: 30,
MinFreeDiskSpaceGB: 1,
MaxMemoryMB: 256,
}
if Conf.TSDB.DataPath != "" {
config.DataPath = Conf.TSDB.DataPath
}
if Conf.TSDB.RetentionDays > 0 {
config.RetentionDays = Conf.TSDB.RetentionDays
}
if Conf.TSDB.MinFreeDiskSpaceGB > 0 {
config.MinFreeDiskSpaceGB = Conf.TSDB.MinFreeDiskSpaceGB
}
if Conf.TSDB.MaxMemoryMB > 0 {
config.MaxMemoryMB = Conf.TSDB.MaxMemoryMB
}
if Conf.TSDB.WriteBufferSize > 0 {
config.WriteBufferSize = Conf.TSDB.WriteBufferSize
}
if Conf.TSDB.WriteBufferFlushInterval > 0 {
config.WriteBufferFlushInterval = time.Duration(Conf.TSDB.WriteBufferFlushInterval) * time.Second
}
if !config.Enabled() {
log.Println("NEZHA>> TSDB is disabled (tsdb.data_path not configured)")
if DB != nil {
return DB.AutoMigrate(model.ServiceHistory{})
}
return nil
}
var err error
TSDBShared, err = tsdb.Open(config)
if err != nil {
return err
}
log.Println("NEZHA>> TSDB initialized successfully")
if DB != nil && DB.Migrator().HasTable("service_histories") {
log.Println("NEZHA>> Dropping legacy service_histories table (TSDB is now enabled). Historical data will NOT be migrated.")
if err := DB.Migrator().DropTable("service_histories"); err != nil {
log.Printf("NEZHA>> Warning: failed to drop service_histories table: %v", err)
}
}
return nil
}
func TSDBEnabled() bool {
return TSDBShared != nil && !TSDBShared.IsClosed()
}
func CloseTSDB() {
if TSDBShared != nil {
TSDBShared.Close()
}
}