[功能] 添加数据库观测数据清理功能,支持手动和自动清理策略

This commit is contained in:
ryan
2026-03-19 20:48:45 +08:00
parent a1b3204204
commit 9eeccb5fc6
16 changed files with 795 additions and 18 deletions
+7
View File
@@ -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` 表。当前常用项包括:
+2
View File
@@ -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"
+34
View File
@@ -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)
}
+22
View File
@@ -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{
+5
View File
@@ -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()
+10 -17
View File
@@ -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 {
@@ -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)
}
@@ -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)
}
+8
View File
@@ -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
+19
View File
@@ -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])
+1
View File
@@ -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())
@@ -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 {
BootstrapTokenPayload,
DatabaseCleanupPayload,
DatabaseCleanupResult,
GeoIPLookupResult,
OptionItem,
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() {
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 { 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<CleanupModalState | null>(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 (
<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') {
return (
<div className="space-y-6">
@@ -2032,6 +2246,83 @@ export function SettingsPage() {
</div>
{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>
);
}
@@ -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;