diff --git a/docs/app-config.md b/docs/app-config.md index 9807be01..d1073cbc 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -57,12 +57,19 @@ go run . --port 3000 --log-dir ./logs | `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` | | `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` | | `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` | +| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` | +| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` | | `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` | | `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` | | `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` | | `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` | | `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` | +说明: + +* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据 +* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录 + ### 1.4 OpenResty 参数 OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括: diff --git a/openflare_server/common/constants.go b/openflare_server/common/constants.go index 2b693c8d..f8877f5e 100644 --- a/openflare_server/common/constants.go +++ b/openflare_server/common/constants.go @@ -55,6 +55,8 @@ var NodeOfflineThreshold = 2 * time.Minute var AgentHeartbeatInterval = 10000 // milliseconds var AgentUpdateRepo = "Rain-kl/OpenFlare" var GeoIPProvider = "ipinfo" +var DatabaseAutoCleanupEnabled = false +var DatabaseAutoCleanupRetentionDays = 30 // V5 OpenResty performance settings (hot-reloadable via Option table) var OpenRestyWorkerProcesses = "auto" diff --git a/openflare_server/controller/database.go b/openflare_server/controller/database.go new file mode 100644 index 00000000..29a4989b --- /dev/null +++ b/openflare_server/controller/database.go @@ -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) +} diff --git a/openflare_server/controller/option.go b/openflare_server/controller/option.go index 7ff7d2b0..77d8143d 100644 --- a/openflare_server/controller/option.go +++ b/openflare_server/controller/option.go @@ -73,6 +73,21 @@ func validateGeoIPOption(key string, value string) error { 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 { trimmed := strings.TrimSpace(value) @@ -271,6 +286,13 @@ func UpdateOption(c *gin.Context) { }) 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) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/openflare_server/main.go b/openflare_server/main.go index 3d4316dc..00b98ea1 100644 --- a/openflare_server/main.go +++ b/openflare_server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "fmt" "github.com/gin-contrib/sessions" @@ -13,6 +14,7 @@ import ( "openflare/middleware" "openflare/model" "openflare/router" + "openflare/service" "openflare/utils/geoip" "os" "strconv" @@ -67,6 +69,9 @@ func main() { // Initialize options model.InitOptionMap() geoip.InitGeoIP() + backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background()) + defer cancelBackgroundTasks() + service.StartDatabaseAutoCleanupScheduler(backgroundCtx) // Initialize HTTP server server := gin.Default() diff --git a/openflare_server/model/node_access_log.go b/openflare_server/model/node_access_log.go index f5b79401..19971154 100644 --- a/openflare_server/model/node_access_log.go +++ b/openflare_server/model/node_access_log.go @@ -240,14 +240,13 @@ func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAcc } func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) { - for _, table := range observabilityShardTables("node_access_logs") { - result := DB.Table(table).Where("logged_at < ?", before).Delete(&NodeAccessLog{}) - if result.Error != nil { - return deleted, result.Error - } - deleted += result.RowsAffected - } - return deleted, nil + return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB { + return tx.Where("logged_at < ?", before) + }) +} + +func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) { + return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil) } 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) { - db = normalizeShardedDB(db) - for _, table := range observabilityShardTables("node_access_logs") { - 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 + return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB { + return tx.Where("node_id = ? AND logged_at < ?", nodeID, before) + }) } func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB { diff --git a/openflare_server/model/node_metric_snapshot.go b/openflare_server/model/node_metric_snapshot.go index 434c4b57..70d9ace7 100644 --- a/openflare_server/model/node_metric_snapshot.go +++ b/openflare_server/model/node_metric_snapshot.go @@ -104,3 +104,13 @@ func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time) } 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) +} diff --git a/openflare_server/model/node_request_report.go b/openflare_server/model/node_request_report.go index 1ed9d564..262c0241 100644 --- a/openflare_server/model/node_request_report.go +++ b/openflare_server/model/node_request_report.go @@ -99,3 +99,13 @@ func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Ti } 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) +} diff --git a/openflare_server/model/option.go b/openflare_server/model/option.go index 2945fcc5..5306959f 100644 --- a/openflare_server/model/option.go +++ b/openflare_server/model/option.go @@ -56,6 +56,8 @@ func InitOptionMap() { common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds())) common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo 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["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections) common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile) @@ -219,6 +221,12 @@ func updateOptionMap(key string, value string) { common.GeoIPProvider = value 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": if strings.TrimSpace(value) != "" { common.OpenRestyWorkerProcesses = value diff --git a/openflare_server/model/sharding.go b/openflare_server/model/sharding.go index 704ba45a..0f232d5a 100644 --- a/openflare_server/model/sharding.go +++ b/openflare_server/model/sharding.go @@ -205,6 +205,25 @@ func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx 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) { sort.Slice(items, func(i int, j int) bool { return less(items[i], items[j]) diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 797c1733..2d903c2f 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -55,6 +55,7 @@ func SetApiRouter(router *gin.Engine) { optionRoute.GET("/", controller.GetOptions) optionRoute.POST("/update", controller.UpdateOption) optionRoute.POST("/geoip/lookup", controller.LookupGeoIP) + optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability) } updateRoute := apiRouter.Group("/update") updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) diff --git a/openflare_server/service/database_maintenance.go b/openflare_server/service/database_maintenance.go new file mode 100644 index 00000000..8a3c0a76 --- /dev/null +++ b/openflare_server/service/database_maintenance.go @@ -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") + } +} diff --git a/openflare_server/service/database_maintenance_test.go b/openflare_server/service/database_maintenance_test.go new file mode 100644 index 00000000..26d1f202 --- /dev/null +++ b/openflare_server/service/database_maintenance_test.go @@ -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) + } +} diff --git a/openflare_server/web/features/settings/api/settings.ts b/openflare_server/web/features/settings/api/settings.ts index 624c194a..d4f1f394 100644 --- a/openflare_server/web/features/settings/api/settings.ts +++ b/openflare_server/web/features/settings/api/settings.ts @@ -2,6 +2,8 @@ import { apiRequest } from '@/lib/api/client'; import type { BootstrapTokenPayload, + DatabaseCleanupPayload, + DatabaseCleanupResult, GeoIPLookupResult, OptionItem, SettingsProfile, @@ -26,6 +28,13 @@ export function lookupGeoIP(provider: string, ip: string) { }); } +export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) { + return apiRequest('/option/database/cleanup', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + export function getBootstrapToken() { return apiRequest('/nodes/bootstrap-token'); } diff --git a/openflare_server/web/features/settings/components/settings-page.tsx b/openflare_server/web/features/settings/components/settings-page.tsx index 3e028bd3..b20bf996 100644 --- a/openflare_server/web/features/settings/components/settings-page.tsx +++ b/openflare_server/web/features/settings/components/settings-page.tsx @@ -7,6 +7,7 @@ import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; +import { AppModal } from '@/components/ui/app-modal'; import { TurnstileWidget } from '@/components/forms/turnstile-widget'; import { useAuth } from '@/components/providers/auth-provider'; import { PageHeader } from '@/components/layout/page-header'; @@ -17,6 +18,7 @@ import { getPublicStatus } from '@/features/auth/api/public'; import { bindEmail, bindWeChat, + cleanupDatabaseObservability, generateAccessToken, getBootstrapToken, getOptions, @@ -28,12 +30,15 @@ import { } from '@/features/settings/api/settings'; import type { BootstrapTokenPayload, + DatabaseCleanupResult, + DatabaseCleanupTarget, GeoIPLookupResult, OptionItem, UpdateSelfPayload, } from '@/features/settings/types'; import { CodeBlock, + DangerButton, PrimaryButton, ResourceField, ResourceInput, @@ -126,6 +131,11 @@ const defaultOtherFields = { Footer: '', }; +const defaultDatabaseFields = { + DatabaseAutoCleanupEnabled: false, + DatabaseAutoCleanupRetentionDays: '30', +}; + const defaultProfileFields: UpdateSelfPayload = { username: '', display_name: '', @@ -137,7 +147,17 @@ type FeedbackState = { 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) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; @@ -227,12 +247,16 @@ export function SettingsPage() { defaultOperationFields, ); const [otherFields, setOtherFields] = useState(defaultOtherFields); + const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields); const [accessToken, setAccessToken] = useState(''); const [wechatCode, setWeChatCode] = useState(''); const [emailAddress, setEmailAddress] = useState(''); const [emailCode, setEmailCode] = useState(''); const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); + const [cleanupModalState, setCleanupModalState] = + useState(null); + const [cleanupRetentionDays, setCleanupRetentionDays] = useState(''); const isRoot = (user?.role ?? 0) >= 100; @@ -410,6 +434,14 @@ export function SettingsPage() { About: optionMap.About ?? '', Footer: optionMap.Footer ?? '', }); + setDatabaseFields({ + DatabaseAutoCleanupEnabled: toBoolean( + optionMap.DatabaseAutoCleanupEnabled, + false, + ), + DatabaseAutoCleanupRetentionDays: + optionMap.DatabaseAutoCleanupRetentionDays ?? '30', + }); }, [optionsQuery.data, publicStatusQuery.data?.server_address]); const rotateTokenMutation = useMutation({ @@ -451,6 +483,23 @@ export function SettingsPage() { 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 discoveryCommand = isRoot && operationFields.ServerAddress && discoveryToken @@ -476,6 +525,11 @@ export function SettingsPage() { label: '系统设置', description: '登录注册、SMTP、OAuth、限流与风控开关。', }, + { + key: 'database' as const, + label: '数据库', + description: '观测数据清理与每日自动保留策略。', + }, { key: 'other' as const, label: '其他设置', @@ -1270,6 +1324,166 @@ export function SettingsPage() { ); } + if (activeTab === 'database') { + return ( +
+ + 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' + ? '保存中...' + : '保存自动清理'} + + } + > +
+ + setDatabaseFields((previous) => ({ + ...previous, + DatabaseAutoCleanupEnabled: checked, + })) + } + /> +
+ + + setDatabaseFields((previous) => ({ + ...previous, + DatabaseAutoCleanupRetentionDays: event.target.value, + })) + } + placeholder="例如 30" + /> + +
+
+

+ 触发频率 +

+

+ 每天一次 +

+
+
+

+ 默认执行时间 +

+

+ 凌晨 3:00 +

+
+
+

+ 生效范围 +

+

+ 三类观测表 +

+
+
+
+
+
+ + +
+ {[ + { + 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) => ( +
+
+
+

+ {item.label} +

+

+ {item.description} +

+
+ { + setCleanupRetentionDays(''); + setCleanupModalState({ + target: item.target, + label: item.label, + }); + }} + > + 清理数据 + +
+
+ ))} +
+
+
+ ); + } + if (activeTab === 'system') { return (
@@ -2032,6 +2246,83 @@ export function SettingsPage() {
{renderTabContent()} + + { + if (databaseCleanupMutation.isPending) { + return; + } + setCleanupModalState(null); + }} + footer={ +
+ setCleanupModalState(null)} + disabled={databaseCleanupMutation.isPending} + > + 取消 + + { + 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 ? '清理中...' : '确认清理'} + +
+ } + > +
+
+ 该操作会直接删除数据库中的历史观测数据,删除后无法恢复,请确认当前选择的数据类型和保留范围无误。 +
+ + setCleanupRetentionDays(event.target.value)} + placeholder="例如 30;留空则全部删除" + /> + + {databaseCleanupMutation.isError ? ( + + ) : null} +
+
); } diff --git a/openflare_server/web/features/settings/types.ts b/openflare_server/web/features/settings/types.ts index 00d7f374..924bd478 100644 --- a/openflare_server/web/features/settings/types.ts +++ b/openflare_server/web/features/settings/types.ts @@ -18,6 +18,25 @@ export interface GeoIPLookupResult { 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 { username: string; display_name: string;