mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
[功能] 添加数据库观测数据清理功能,支持手动和自动清理策略
This commit is contained in:
@@ -57,12 +57,19 @@ go run . --port 3000 --log-dir ./logs
|
|||||||
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
||||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
||||||
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
||||||
|
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
|
||||||
|
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
|
||||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
||||||
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
||||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据
|
||||||
|
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录
|
||||||
|
|
||||||
### 1.4 OpenResty 参数
|
### 1.4 OpenResty 参数
|
||||||
|
|
||||||
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
|
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ var NodeOfflineThreshold = 2 * time.Minute
|
|||||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||||
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
||||||
var GeoIPProvider = "ipinfo"
|
var GeoIPProvider = "ipinfo"
|
||||||
|
var DatabaseAutoCleanupEnabled = false
|
||||||
|
var DatabaseAutoCleanupRetentionDays = 30
|
||||||
|
|
||||||
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
||||||
var OpenRestyWorkerProcesses = "auto"
|
var OpenRestyWorkerProcesses = "auto"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"openflare/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanupDatabaseObservability godoc
|
||||||
|
// @Summary Cleanup observability tables
|
||||||
|
// @Tags Options
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Router /api/option/database/cleanup [post]
|
||||||
|
func CleanupDatabaseObservability(c *gin.Context) {
|
||||||
|
var input service.DatabaseCleanupInput
|
||||||
|
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.CleanupDatabaseObservability(input)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, result)
|
||||||
|
}
|
||||||
@@ -73,6 +73,21 @@ func validateGeoIPOption(key string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDatabaseCleanupOption(key string, value string) error {
|
||||||
|
switch key {
|
||||||
|
case "DatabaseAutoCleanupEnabled":
|
||||||
|
return validateBooleanOption(key, value)
|
||||||
|
case "DatabaseAutoCleanupRetentionDays":
|
||||||
|
intValue, err := strconv.Atoi(value)
|
||||||
|
if err != nil || intValue < 1 {
|
||||||
|
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateOpenRestyOption(key string, value string) error {
|
func validateOpenRestyOption(key string, value string) error {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
|
|
||||||
@@ -271,6 +286,13 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err = validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
err = model.UpdateOption(option.Key, option.Value)
|
err = model.UpdateOption(option.Key, option.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"openflare/middleware"
|
"openflare/middleware"
|
||||||
"openflare/model"
|
"openflare/model"
|
||||||
"openflare/router"
|
"openflare/router"
|
||||||
|
"openflare/service"
|
||||||
"openflare/utils/geoip"
|
"openflare/utils/geoip"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -67,6 +69,9 @@ func main() {
|
|||||||
// Initialize options
|
// Initialize options
|
||||||
model.InitOptionMap()
|
model.InitOptionMap()
|
||||||
geoip.InitGeoIP()
|
geoip.InitGeoIP()
|
||||||
|
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
|
||||||
|
defer cancelBackgroundTasks()
|
||||||
|
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
server := gin.Default()
|
server := gin.Default()
|
||||||
|
|||||||
@@ -240,14 +240,13 @@ func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAcc
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
|
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
|
||||||
for _, table := range observabilityShardTables("node_access_logs") {
|
return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
|
||||||
result := DB.Table(table).Where("logged_at < ?", before).Delete(&NodeAccessLog{})
|
return tx.Where("logged_at < ?", before)
|
||||||
if result.Error != nil {
|
})
|
||||||
return deleted, result.Error
|
}
|
||||||
}
|
|
||||||
deleted += result.RowsAffected
|
func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) {
|
||||||
}
|
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil)
|
||||||
return deleted, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
|
func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
|
||||||
@@ -279,15 +278,9 @@ func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) {
|
func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) {
|
||||||
db = normalizeShardedDB(db)
|
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
|
||||||
for _, table := range observabilityShardTables("node_access_logs") {
|
return tx.Where("node_id = ? AND logged_at < ?", nodeID, before)
|
||||||
result := db.Table(table).Where("node_id = ? AND logged_at < ?", nodeID, before).Delete(&NodeAccessLog{})
|
})
|
||||||
if result.Error != nil {
|
|
||||||
return deleted, result.Error
|
|
||||||
}
|
|
||||||
deleted += result.RowsAffected
|
|
||||||
}
|
|
||||||
return deleted, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||||
|
|||||||
@@ -104,3 +104,13 @@ func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time)
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteNodeMetricSnapshotsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||||
|
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, func(tx *gorm.DB) *gorm.DB {
|
||||||
|
return tx.Where("captured_at < ?", before)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAllNodeMetricSnapshots(db *gorm.DB) (int64, error) {
|
||||||
|
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,3 +99,13 @@ func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Ti
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteNodeRequestReportsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||||
|
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, func(tx *gorm.DB) *gorm.DB {
|
||||||
|
return tx.Where("window_ended_at < ?", before)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAllNodeRequestReports(db *gorm.DB) (int64, error) {
|
||||||
|
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
||||||
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
||||||
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
|
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
|
||||||
|
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
|
||||||
|
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
|
||||||
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
|
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
|
||||||
common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections)
|
common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections)
|
||||||
common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile)
|
common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile)
|
||||||
@@ -219,6 +221,12 @@ func updateOptionMap(key string, value string) {
|
|||||||
common.GeoIPProvider = value
|
common.GeoIPProvider = value
|
||||||
shouldRefreshGeoIP = true
|
shouldRefreshGeoIP = true
|
||||||
}
|
}
|
||||||
|
case "DatabaseAutoCleanupEnabled":
|
||||||
|
common.DatabaseAutoCleanupEnabled = value == "true"
|
||||||
|
case "DatabaseAutoCleanupRetentionDays":
|
||||||
|
if v, err := strconv.Atoi(value); err == nil && v >= 1 {
|
||||||
|
common.DatabaseAutoCleanupRetentionDays = v
|
||||||
|
}
|
||||||
case "OpenRestyWorkerProcesses":
|
case "OpenRestyWorkerProcesses":
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
common.OpenRestyWorkerProcesses = value
|
common.OpenRestyWorkerProcesses = value
|
||||||
|
|||||||
@@ -205,6 +205,25 @@ func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteAcrossShards(db *gorm.DB, baseTable string, model any, apply func(tx *gorm.DB) *gorm.DB) (int64, error) {
|
||||||
|
db = normalizeShardedDB(db)
|
||||||
|
var deleted int64
|
||||||
|
for _, table := range observabilityShardTables(baseTable) {
|
||||||
|
tx := db.Table(table)
|
||||||
|
if apply != nil {
|
||||||
|
tx = apply(tx)
|
||||||
|
} else {
|
||||||
|
tx = tx.Session(&gorm.Session{AllowGlobalUpdate: true})
|
||||||
|
}
|
||||||
|
result := tx.Delete(model)
|
||||||
|
if result.Error != nil {
|
||||||
|
return deleted, result.Error
|
||||||
|
}
|
||||||
|
deleted += result.RowsAffected
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
func sortShardRows[T any](items []T, less func(left T, right T) bool) {
|
func sortShardRows[T any](items []T, less func(left T, right T) bool) {
|
||||||
sort.Slice(items, func(i int, j int) bool {
|
sort.Slice(items, func(i int, j int) bool {
|
||||||
return less(items[i], items[j])
|
return less(items[i], items[j])
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
optionRoute.GET("/", controller.GetOptions)
|
optionRoute.GET("/", controller.GetOptions)
|
||||||
optionRoute.POST("/update", controller.UpdateOption)
|
optionRoute.POST("/update", controller.UpdateOption)
|
||||||
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||||
|
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
|
||||||
}
|
}
|
||||||
updateRoute := apiRouter.Group("/update")
|
updateRoute := apiRouter.Group("/update")
|
||||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"openflare/common"
|
||||||
|
"openflare/model"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DatabaseCleanupTargetAccessLogs = "node_access_logs"
|
||||||
|
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
|
||||||
|
DatabaseCleanupTargetRequestReports = "node_request_reports"
|
||||||
|
)
|
||||||
|
|
||||||
|
var databaseCleanupTargets = map[string]string{
|
||||||
|
DatabaseCleanupTargetAccessLogs: "访问日志",
|
||||||
|
DatabaseCleanupTargetMetricSnapshots: "性能快照",
|
||||||
|
DatabaseCleanupTargetRequestReports: "请求聚合",
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseCleanupInput struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RetentionDays *int `json:"retention_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseCleanupResult struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
TargetLabel string `json:"target_label"`
|
||||||
|
DeletedCount int64 `json:"deleted_count"`
|
||||||
|
DeleteAll bool `json:"delete_all"`
|
||||||
|
RetentionDays *int `json:"retention_days,omitempty"`
|
||||||
|
Cutoff *time.Time `json:"cutoff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseAutoCleanupSummary struct {
|
||||||
|
RetentionDays int `json:"retention_days"`
|
||||||
|
ExecutedAt time.Time `json:"executed_at"`
|
||||||
|
Results []DatabaseCleanupResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupDatabaseObservability(input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
|
||||||
|
target := strings.TrimSpace(input.Target)
|
||||||
|
targetLabel, ok := databaseCleanupTargets[target]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
|
||||||
|
return nil, errors.New("retention_days 必须为大于 0 的整数")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DatabaseCleanupResult{
|
||||||
|
Target: target,
|
||||||
|
TargetLabel: targetLabel,
|
||||||
|
DeleteAll: input.RetentionDays == nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RetentionDays == nil {
|
||||||
|
deleted, err := deleteAllObservabilityRows(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.DeletedCount = deleted
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := *input.RetentionDays
|
||||||
|
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
|
||||||
|
deleted, err := deleteObservabilityRowsBefore(target, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.DeletedCount = deleted
|
||||||
|
result.RetentionDays = &retentionDays
|
||||||
|
result.Cutoff = &cutoff
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
|
||||||
|
if !common.DatabaseAutoCleanupEnabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if common.DatabaseAutoCleanupRetentionDays < 1 {
|
||||||
|
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := common.DatabaseAutoCleanupRetentionDays
|
||||||
|
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
|
||||||
|
for _, target := range []string{
|
||||||
|
DatabaseCleanupTargetAccessLogs,
|
||||||
|
DatabaseCleanupTargetMetricSnapshots,
|
||||||
|
DatabaseCleanupTargetRequestReports,
|
||||||
|
} {
|
||||||
|
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||||
|
Target: target,
|
||||||
|
RetentionDays: &retentionDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DatabaseAutoCleanupSummary{
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
ExecutedAt: now.UTC(),
|
||||||
|
Results: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartDatabaseAutoCleanupScheduler(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
wait := time.Until(nextDatabaseAutoCleanupTime(time.Now()))
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("database auto cleanup failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if summary == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalDeleted := int64(0)
|
||||||
|
for _, item := range summary.Results {
|
||||||
|
totalDeleted += item.DeletedCount
|
||||||
|
}
|
||||||
|
slog.Info(
|
||||||
|
"database auto cleanup completed",
|
||||||
|
"retention_days",
|
||||||
|
summary.RetentionDays,
|
||||||
|
"deleted_count",
|
||||||
|
totalDeleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextDatabaseAutoCleanupTime(now time.Time) time.Time {
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
|
||||||
|
if !next.After(now) {
|
||||||
|
next = next.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAllObservabilityRows(target string) (int64, error) {
|
||||||
|
switch target {
|
||||||
|
case DatabaseCleanupTargetAccessLogs:
|
||||||
|
return model.DeleteAllNodeAccessLogs(nil)
|
||||||
|
case DatabaseCleanupTargetMetricSnapshots:
|
||||||
|
return model.DeleteAllNodeMetricSnapshots(nil)
|
||||||
|
case DatabaseCleanupTargetRequestReports:
|
||||||
|
return model.DeleteAllNodeRequestReports(nil)
|
||||||
|
default:
|
||||||
|
return 0, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteObservabilityRowsBefore(target string, cutoff time.Time) (int64, error) {
|
||||||
|
switch target {
|
||||||
|
case DatabaseCleanupTargetAccessLogs:
|
||||||
|
return model.DeleteNodeAccessLogsBefore(cutoff)
|
||||||
|
case DatabaseCleanupTargetMetricSnapshots:
|
||||||
|
return model.DeleteNodeMetricSnapshotsBefore(nil, cutoff)
|
||||||
|
case DatabaseCleanupTargetRequestReports:
|
||||||
|
return model.DeleteNodeRequestReportsBefore(nil, cutoff)
|
||||||
|
default:
|
||||||
|
return 0, errors.New("unsupported cleanup target")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"openflare/common"
|
||||||
|
"openflare/model"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-10 * 24 * time.Hour),
|
||||||
|
CPUUsagePercent: 10,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed old metric snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-12 * time.Hour),
|
||||||
|
CPUUsagePercent: 20,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed recent metric snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := 7
|
||||||
|
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||||
|
Target: DatabaseCleanupTargetMetricSnapshots,
|
||||||
|
RetentionDays: &retentionDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.DeleteAll {
|
||||||
|
t.Fatal("expected retention cleanup instead of delete_all")
|
||||||
|
}
|
||||||
|
if result.DeletedCount != 1 {
|
||||||
|
t.Fatalf("expected 1 deleted row, got %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := model.ListMetricSnapshotsSince(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].CPUUsagePercent != 20 {
|
||||||
|
t.Fatalf("unexpected remaining metric snapshots: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := model.DB.Create(&model.NodeAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-3 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.1",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/one",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed first access log: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&model.NodeAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-2 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.2",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/two",
|
||||||
|
StatusCode: 502,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed second access log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||||
|
Target: DatabaseCleanupTargetAccessLogs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.DeleteAll || result.DeletedCount != 2 {
|
||||||
|
t.Fatalf("unexpected delete-all result: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("expected all access logs deleted, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := model.DB.Create(&model.NodeAccessLog{
|
||||||
|
NodeID: "node-a",
|
||||||
|
LoggedAt: now.Add(-48 * time.Hour),
|
||||||
|
RemoteAddr: "203.0.113.10",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/access",
|
||||||
|
StatusCode: 200,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed access log: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||||
|
NodeID: "node-a",
|
||||||
|
CapturedAt: now.Add(-48 * time.Hour),
|
||||||
|
CPUUsagePercent: 10,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed metric snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&model.NodeRequestReport{
|
||||||
|
NodeID: "node-a",
|
||||||
|
WindowStartedAt: now.Add(-49 * time.Hour),
|
||||||
|
WindowEndedAt: now.Add(-48 * time.Hour),
|
||||||
|
RequestCount: 15,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed request report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousEnabled := common.DatabaseAutoCleanupEnabled
|
||||||
|
previousRetentionDays := common.DatabaseAutoCleanupRetentionDays
|
||||||
|
common.DatabaseAutoCleanupEnabled = true
|
||||||
|
common.DatabaseAutoCleanupRetentionDays = 1
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.DatabaseAutoCleanupEnabled = previousEnabled
|
||||||
|
common.DatabaseAutoCleanupRetentionDays = previousRetentionDays
|
||||||
|
})
|
||||||
|
|
||||||
|
summary, err := RunDatabaseAutoCleanupOnce(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunDatabaseAutoCleanupOnce failed: %v", err)
|
||||||
|
}
|
||||||
|
if summary == nil || len(summary.Results) != 3 {
|
||||||
|
t.Fatalf("unexpected auto cleanup summary: %+v", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(accessLogs) != 0 {
|
||||||
|
t.Fatalf("expected auto cleanup to delete access logs, got %+v", accessLogs)
|
||||||
|
}
|
||||||
|
metricSnapshots, err := model.ListMetricSnapshotsSince(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(metricSnapshots) != 0 {
|
||||||
|
t.Fatalf("expected auto cleanup to delete metric snapshots, got %+v", metricSnapshots)
|
||||||
|
}
|
||||||
|
requestReports, err := model.ListRequestReportsSince(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRequestReportsSince failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(requestReports) != 0 {
|
||||||
|
t.Fatalf("expected auto cleanup to delete request reports, got %+v", requestReports)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { apiRequest } from '@/lib/api/client';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
BootstrapTokenPayload,
|
||||||
|
DatabaseCleanupPayload,
|
||||||
|
DatabaseCleanupResult,
|
||||||
GeoIPLookupResult,
|
GeoIPLookupResult,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
SettingsProfile,
|
SettingsProfile,
|
||||||
@@ -26,6 +28,13 @@ export function lookupGeoIP(provider: string, ip: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
|
||||||
|
return apiRequest<DatabaseCleanupResult>('/option/database/cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getBootstrapToken() {
|
export function getBootstrapToken() {
|
||||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EmptyState } from '@/components/feedback/empty-state';
|
|||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
@@ -17,6 +18,7 @@ import { getPublicStatus } from '@/features/auth/api/public';
|
|||||||
import {
|
import {
|
||||||
bindEmail,
|
bindEmail,
|
||||||
bindWeChat,
|
bindWeChat,
|
||||||
|
cleanupDatabaseObservability,
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
getBootstrapToken,
|
getBootstrapToken,
|
||||||
getOptions,
|
getOptions,
|
||||||
@@ -28,12 +30,15 @@ import {
|
|||||||
} from '@/features/settings/api/settings';
|
} from '@/features/settings/api/settings';
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
BootstrapTokenPayload,
|
||||||
|
DatabaseCleanupResult,
|
||||||
|
DatabaseCleanupTarget,
|
||||||
GeoIPLookupResult,
|
GeoIPLookupResult,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
UpdateSelfPayload,
|
UpdateSelfPayload,
|
||||||
} from '@/features/settings/types';
|
} from '@/features/settings/types';
|
||||||
import {
|
import {
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
|
DangerButton,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
ResourceInput,
|
ResourceInput,
|
||||||
@@ -126,6 +131,11 @@ const defaultOtherFields = {
|
|||||||
Footer: '',
|
Footer: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultDatabaseFields = {
|
||||||
|
DatabaseAutoCleanupEnabled: false,
|
||||||
|
DatabaseAutoCleanupRetentionDays: '30',
|
||||||
|
};
|
||||||
|
|
||||||
const defaultProfileFields: UpdateSelfPayload = {
|
const defaultProfileFields: UpdateSelfPayload = {
|
||||||
username: '',
|
username: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
@@ -137,7 +147,17 @@ type FeedbackState = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsTab = 'personal' | 'operation' | 'system' | 'other';
|
type CleanupModalState = {
|
||||||
|
target: DatabaseCleanupTarget;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsTab =
|
||||||
|
| 'personal'
|
||||||
|
| 'operation'
|
||||||
|
| 'database'
|
||||||
|
| 'system'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
@@ -227,12 +247,16 @@ export function SettingsPage() {
|
|||||||
defaultOperationFields,
|
defaultOperationFields,
|
||||||
);
|
);
|
||||||
const [otherFields, setOtherFields] = useState(defaultOtherFields);
|
const [otherFields, setOtherFields] = useState(defaultOtherFields);
|
||||||
|
const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields);
|
||||||
const [accessToken, setAccessToken] = useState('');
|
const [accessToken, setAccessToken] = useState('');
|
||||||
const [wechatCode, setWeChatCode] = useState('');
|
const [wechatCode, setWeChatCode] = useState('');
|
||||||
const [emailAddress, setEmailAddress] = useState('');
|
const [emailAddress, setEmailAddress] = useState('');
|
||||||
const [emailCode, setEmailCode] = useState('');
|
const [emailCode, setEmailCode] = useState('');
|
||||||
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
||||||
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
||||||
|
const [cleanupModalState, setCleanupModalState] =
|
||||||
|
useState<CleanupModalState | null>(null);
|
||||||
|
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
|
||||||
|
|
||||||
const isRoot = (user?.role ?? 0) >= 100;
|
const isRoot = (user?.role ?? 0) >= 100;
|
||||||
|
|
||||||
@@ -410,6 +434,14 @@ export function SettingsPage() {
|
|||||||
About: optionMap.About ?? '',
|
About: optionMap.About ?? '',
|
||||||
Footer: optionMap.Footer ?? '',
|
Footer: optionMap.Footer ?? '',
|
||||||
});
|
});
|
||||||
|
setDatabaseFields({
|
||||||
|
DatabaseAutoCleanupEnabled: toBoolean(
|
||||||
|
optionMap.DatabaseAutoCleanupEnabled,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
DatabaseAutoCleanupRetentionDays:
|
||||||
|
optionMap.DatabaseAutoCleanupRetentionDays ?? '30',
|
||||||
|
});
|
||||||
}, [optionsQuery.data, publicStatusQuery.data?.server_address]);
|
}, [optionsQuery.data, publicStatusQuery.data?.server_address]);
|
||||||
|
|
||||||
const rotateTokenMutation = useMutation({
|
const rotateTokenMutation = useMutation({
|
||||||
@@ -451,6 +483,23 @@ export function SettingsPage() {
|
|||||||
lookupGeoIP(provider, ip),
|
lookupGeoIP(provider, ip),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const databaseCleanupMutation = useMutation({
|
||||||
|
mutationFn: cleanupDatabaseObservability,
|
||||||
|
onSuccess: (result: DatabaseCleanupResult) => {
|
||||||
|
setCleanupModalState(null);
|
||||||
|
setCleanupRetentionDays('');
|
||||||
|
setFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: result.delete_all
|
||||||
|
? `已清空${result.target_label}数据,共删除 ${result.deleted_count} 条。`
|
||||||
|
: `已清理${result.target_label}中超出保留期的数据,共删除 ${result.deleted_count} 条。`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
||||||
const discoveryCommand =
|
const discoveryCommand =
|
||||||
isRoot && operationFields.ServerAddress && discoveryToken
|
isRoot && operationFields.ServerAddress && discoveryToken
|
||||||
@@ -476,6 +525,11 @@ export function SettingsPage() {
|
|||||||
label: '系统设置',
|
label: '系统设置',
|
||||||
description: '登录注册、SMTP、OAuth、限流与风控开关。',
|
description: '登录注册、SMTP、OAuth、限流与风控开关。',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'database' as const,
|
||||||
|
label: '数据库',
|
||||||
|
description: '观测数据清理与每日自动保留策略。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'other' as const,
|
key: 'other' as const,
|
||||||
label: '其他设置',
|
label: '其他设置',
|
||||||
@@ -1270,6 +1324,166 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'database') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AppCard
|
||||||
|
title="自动数据清理"
|
||||||
|
description="每天凌晨 3 点自动清理超出保留期的观测数据,统一作用于访问日志、性能快照和请求聚合。"
|
||||||
|
action={
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void runBusyAction('database-auto-cleanup', async () => {
|
||||||
|
const retentionDays = Number.parseInt(
|
||||||
|
databaseFields.DatabaseAutoCleanupRetentionDays,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||||
|
throw new Error('自动清理保留天数至少为 1 天。');
|
||||||
|
}
|
||||||
|
await saveOptionEntries(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'DatabaseAutoCleanupEnabled',
|
||||||
|
String(databaseFields.DatabaseAutoCleanupEnabled),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DatabaseAutoCleanupRetentionDays',
|
||||||
|
String(retentionDays),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'数据库自动清理设置已保存。',
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={busyKey === 'database-auto-cleanup'}
|
||||||
|
>
|
||||||
|
{busyKey === 'database-auto-cleanup'
|
||||||
|
? '保存中...'
|
||||||
|
: '保存自动清理'}
|
||||||
|
</PrimaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<ToggleField
|
||||||
|
label="启用每日自动清理"
|
||||||
|
description="开启后,服务端每天自动删除保留天数之外的观测数据。"
|
||||||
|
checked={databaseFields.DatabaseAutoCleanupEnabled}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setDatabaseFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
DatabaseAutoCleanupEnabled: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<ResourceField
|
||||||
|
label="自动清理保留天数"
|
||||||
|
hint="必须至少保留 1 天,服务端不允许配置为 24 小时以内。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={databaseFields.DatabaseAutoCleanupRetentionDays}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDatabaseFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
DatabaseAutoCleanupRetentionDays: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="例如 30"
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||||
|
触发频率
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
每天一次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||||
|
默认执行时间
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
凌晨 3:00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||||
|
生效范围
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
三类观测表
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="数据清理"
|
||||||
|
description="用于手动清理单类观测数据。保留天数留空时会直接删除该类数据的全部历史记录。"
|
||||||
|
>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
target: 'node_access_logs' as const,
|
||||||
|
label: '访问日志',
|
||||||
|
description:
|
||||||
|
'清理 node_access_logs,影响访问明细、IP 汇总与相关趋势查询。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'node_metric_snapshots' as const,
|
||||||
|
label: '性能快照',
|
||||||
|
description:
|
||||||
|
'清理 node_metric_snapshots,影响节点资源趋势和总览资源统计。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'node_request_reports' as const,
|
||||||
|
label: '请求聚合',
|
||||||
|
description:
|
||||||
|
'清理 node_request_reports,影响请求量、错误量与来源聚合展示。',
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.target}
|
||||||
|
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCleanupRetentionDays('');
|
||||||
|
setCleanupModalState({
|
||||||
|
target: item.target,
|
||||||
|
label: item.label,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清理数据
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTab === 'system') {
|
if (activeTab === 'system') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -2032,6 +2246,83 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
|
|
||||||
|
<AppModal
|
||||||
|
isOpen={cleanupModalState !== null}
|
||||||
|
title={`清理${cleanupModalState?.label ?? ''}`}
|
||||||
|
description="输入保留天数后,将只保留该天数范围内的数据;如果留空,则会直接删除该类数据的全部历史记录。"
|
||||||
|
onClose={() => {
|
||||||
|
if (databaseCleanupMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCleanupModalState(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCleanupModalState(null)}
|
||||||
|
disabled={databaseCleanupMutation.isPending}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</SecondaryButton>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
disabled={databaseCleanupMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!cleanupModalState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = cleanupRetentionDays.trim();
|
||||||
|
if (trimmed !== '') {
|
||||||
|
const retentionDays = Number.parseInt(trimmed, 10);
|
||||||
|
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||||
|
setFeedback({
|
||||||
|
tone: 'danger',
|
||||||
|
message: '手动清理保留天数至少为 1 天。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
databaseCleanupMutation.mutate({
|
||||||
|
target: cleanupModalState.target,
|
||||||
|
retention_days: retentionDays,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
databaseCleanupMutation.mutate({
|
||||||
|
target: cleanupModalState.target,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{databaseCleanupMutation.isPending ? '清理中...' : '确认清理'}
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-4 py-4 text-sm leading-6 text-[var(--status-danger-foreground)]">
|
||||||
|
该操作会直接删除数据库中的历史观测数据,删除后无法恢复,请确认当前选择的数据类型和保留范围无误。
|
||||||
|
</div>
|
||||||
|
<ResourceField
|
||||||
|
label="保留天数"
|
||||||
|
hint="留空表示全部删除;填写时必须为大于等于 1 的整数。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={cleanupRetentionDays}
|
||||||
|
onChange={(event) => setCleanupRetentionDays(event.target.value)}
|
||||||
|
placeholder="例如 30;留空则全部删除"
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
{databaseCleanupMutation.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="数据库清理失败"
|
||||||
|
description={getErrorMessage(databaseCleanupMutation.error)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export interface GeoIPLookupResult {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DatabaseCleanupTarget =
|
||||||
|
| 'node_access_logs'
|
||||||
|
| 'node_metric_snapshots'
|
||||||
|
| 'node_request_reports';
|
||||||
|
|
||||||
|
export interface DatabaseCleanupPayload {
|
||||||
|
target: DatabaseCleanupTarget;
|
||||||
|
retention_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseCleanupResult {
|
||||||
|
target: DatabaseCleanupTarget;
|
||||||
|
target_label: string;
|
||||||
|
deleted_count: number;
|
||||||
|
delete_all: boolean;
|
||||||
|
retention_days?: number;
|
||||||
|
cutoff?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateSelfPayload {
|
export interface UpdateSelfPayload {
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user