From 4ae6c2718f9c99a7cbda168e73b3f55e40b78d44 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 31 May 2026 14:09:53 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=8A=82=E7=82=B9=20IP=20=E6=89=8B=E5=8A=A8=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/architecture.md | 2 + docs/en/design/architecture.md | 2 + docs/en/guide/usage.md | 2 + docs/en/reference/configuration.md | 1 + docs/guide/usage.md | 2 + docs/reference/configuration.md | 1 + openflare_server/docs/docs.go | 3 + openflare_server/docs/swagger.json | 5 +- openflare_server/docs/swagger.yaml | 2 + .../model/database_schema_version.go | 2 +- openflare_server/model/main_test.go | 40 ++++ openflare_server/model/migrations.go | 18 +- openflare_server/model/node.go | 1 + openflare_server/service/agent.go | 1 + openflare_server/service/node.go | 26 ++- openflare_server/service/node_update_test.go | 190 ++++++++++++++++++ .../nodes/components/node-detail-page.tsx | 5 +- .../nodes/components/node-editor-modal.tsx | 52 ++++- .../features/nodes/components/nodes-page.tsx | 1 + openflare_server/web/features/nodes/types.ts | 2 + 20 files changed, 351 insertions(+), 7 deletions(-) diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 2326f60a..037a47c0 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -58,6 +58,8 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存 Agent 通过 `openresty_path` 指向的 OpenResty 二进制统一执行校验、reload、启动与重启;未配置时默认调用 `openresty`。Docker 部署时,Agent 镜像内置 OpenResty 二进制,仍走同一套二进制控制逻辑。 +节点 IP 默认由 Agent 注册和心跳上报维护;如果管理端锁定节点 IP,Server 只更新运行状态、版本、观测等运行态字段,不再接受 Agent 上报覆盖该 IP。 + ## Frontend `openflare_server/web` 是正式管理端前端: diff --git a/docs/en/design/architecture.md b/docs/en/design/architecture.md index 5dfec69c..4e9ecea8 100644 --- a/docs/en/design/architecture.md +++ b/docs/en/design/architecture.md @@ -58,6 +58,8 @@ The Server does not directly SSH into nodes, nor does it modify node files onlin The Agent uniformly executes validations, reloads, starts, and restarts via the OpenResty binary pointed to by `openresty_path`; it falls back to calling `openresty` by default when not configured. In Docker deployments, the Agent image includes the OpenResty binary and follows the same binary control logic. +Node IPs are maintained by Agent registration and heartbeat reports by default; when the admin UI locks a node IP, the Server continues updating runtime fields such as status, versions, and observability, but no longer accepts Agent reports to overwrite that IP. + ## Frontend `openflare_server/web` is the official management console frontend: diff --git a/docs/en/guide/usage.md b/docs/en/guide/usage.md index d22970a9..36a451cf 100644 --- a/docs/en/guide/usage.md +++ b/docs/en/guide/usage.md @@ -106,6 +106,8 @@ Node pages answer three questions: | Which version is running? | Current version on the node detail page | | Did the last apply succeed? | Apply logs | +Node IPs are filled automatically by Agent registration and subsequent heartbeats by default. When you enter or change an IP in the admin UI, the node editor enables "Lock node IP" by default; Agent reports will not overwrite the IP while the lock is enabled. After unlocking, the next Agent heartbeat or WebSocket status report can update it again. + Access analytics and resource snapshots provide basic observability. OpenFlare only keeps access details for a controlled time window; it is not a general-purpose log platform. Use a dedicated logging system for long-term log search. ## Common Scenarios diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 0981f2d5..1f1887b5 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -198,6 +198,7 @@ Description: * If `agent.json` does not exist but environment variables such as `OPENFLARE_SERVER_URL` and tokens are sufficient, the Agent can start directly; environment variables take precedence when both exist. * When the Agent is not configured with `node_ip`, it first queries `https://realip.cc` for the real public egress IP, adapting to Docker/NAT scenarios; it falls back to local interface detection on failure, preferring a public IPv4 address. * When the Agent automatically detects a private `node_ip`, the Server prioritizes retaining the public address of the Agent's direct connection during registration/heartbeat phases, avoiding misregistering internal interface addresses in NAT or multi-interface scenarios. +* When "Lock node IP" is enabled in the admin UI, the Server keeps the manually configured node IP and Agent registration, HTTP heartbeat, or WebSocket status reports will not overwrite that field; after unlocking, the next report can fill it again. ## Common Configuration Combinations diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 2d32fd9c..78126607 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -108,6 +108,8 @@ WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent | 当前运行哪个版本 | 节点详情中的当前版本 | | 最近一次应用是否成功 | 应用记录 | +节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。 + 访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。 ## 常见场景 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cab61749..0100a817 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -198,6 +198,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 * 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。 * Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。 * Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。 +* 在管理端开启“锁定节点 IP”后,Server 会保留管理端填写的节点 IP,后续 Agent 注册、HTTP 心跳或 WebSocket 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。 ## 常见配置组合 diff --git a/openflare_server/docs/docs.go b/openflare_server/docs/docs.go index 78e49c4a..e876e41b 100644 --- a/openflare_server/docs/docs.go +++ b/openflare_server/docs/docs.go @@ -2994,6 +2994,9 @@ const docTemplate = `{ "ip": { "type": "string" }, + "ip_manual_override": { + "type": "boolean" + }, "name": { "type": "string" } diff --git a/openflare_server/docs/swagger.json b/openflare_server/docs/swagger.json index c8acf721..322b66ed 100644 --- a/openflare_server/docs/swagger.json +++ b/openflare_server/docs/swagger.json @@ -2991,6 +2991,9 @@ "ip": { "type": "string" }, + "ip_manual_override": { + "type": "boolean" + }, "name": { "type": "string" } @@ -3193,4 +3196,4 @@ "in": "header" } } -} \ No newline at end of file +} diff --git a/openflare_server/docs/swagger.yaml b/openflare_server/docs/swagger.yaml index 40813ce7..8db19961 100644 --- a/openflare_server/docs/swagger.yaml +++ b/openflare_server/docs/swagger.yaml @@ -241,6 +241,8 @@ definitions: type: string ip: type: string + ip_manual_override: + type: boolean name: type: string type: object diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index 28b2ff2e..5708387a 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 14 + currentDatabaseSchemaVersion = 15 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/main_test.go b/openflare_server/model/main_test.go index 73206aa0..356e6820 100644 --- a/openflare_server/model/main_test.go +++ b/openflare_server/model/main_test.go @@ -883,3 +883,43 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t t.Fatal("expected schema version to remain unset after failed validation") } } + +func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) { + db := openBareTestSQLiteDB(t, "node-ip-manual-override-migration.db") + if err := registerSharding(db, "sqlite"); err != nil { + t.Fatalf("register sharding: %v", err) + } + if err := applyCurrentSchema(db, "sqlite"); err != nil { + t.Fatalf("apply current schema: %v", err) + } + if err := ensureDefaultWAFRuleGroup(db); err != nil { + t.Fatalf("ensure default waf rule group: %v", err) + } + if err := db.Migrator().DropColumn(&Node{}, "ip_manual_override"); err != nil { + t.Fatalf("drop ip_manual_override column: %v", err) + } + if db.Migrator().HasColumn(&Node{}, "ip_manual_override") { + t.Fatal("expected test database to simulate schema v14 without ip_manual_override") + } + if err := saveDatabaseSchemaVersion(db, 14); err != nil { + t.Fatalf("save schema version: %v", err) + } + + if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { + t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) + } + + if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") { + t.Fatal("expected migration to add nodes.ip_manual_override") + } + version, exists, err := loadDatabaseSchemaVersion(db) + if err != nil { + t.Fatalf("loadDatabaseSchemaVersion: %v", err) + } + if !exists { + t.Fatal("expected schema version record to exist") + } + if version != currentDatabaseSchemaVersion { + t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion) + } +} diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index 352ae43b..e7d0d69f 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -1472,6 +1472,21 @@ func validateDatabaseSchemaV14(db *gorm.DB, backend string) error { return nil } +// migrateV15 adds the node IP manual override flag. +func migrateV15(db *gorm.DB, backend string) error { + return applyCurrentSchema(db, backend) +} + +func validateDatabaseSchemaV15(db *gorm.DB, backend string) error { + if err := validateDatabaseSchemaV14(db, backend); err != nil { + return err + } + if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") { + return fmt.Errorf("column nodes.ip_manual_override is missing") + } + return nil +} + func databaseSchemaMigrations() []databaseSchemaMigration { return []databaseSchemaMigration{ {fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2}, @@ -1487,6 +1502,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration { {fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12}, {fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13}, {fromVersion: 13, toVersion: 14, migrate: migrateV14, validate: validateDatabaseSchemaV14}, + {fromVersion: 14, toVersion: 15, migrate: migrateV15, validate: validateDatabaseSchemaV15}, } } @@ -1575,7 +1591,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error { if err := ensureDefaultWAFRuleGroup(db); err != nil { return err } - if err := validateDatabaseSchemaV13(db, backend); err != nil { + if err := validateDatabaseSchemaV15(db, backend); err != nil { return err } return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion) diff --git a/openflare_server/model/node.go b/openflare_server/model/node.go index d06892bb..a7f8e679 100644 --- a/openflare_server/model/node.go +++ b/openflare_server/model/node.go @@ -7,6 +7,7 @@ type Node struct { NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"` Name string `json:"name" gorm:"size:128;not null"` IP string `json:"ip" gorm:"size:64;not null"` + IPManualOverride bool `json:"ip_manual_override" gorm:"not null;default:false"` GeoName string `json:"geo_name" gorm:"size:128"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index 8a2bdcb4..abf7b889 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -115,6 +115,7 @@ type NodeView struct { NodeID string `json:"node_id"` Name string `json:"name"` IP string `json:"ip"` + IPManualOverride bool `json:"ip_manual_override"` GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index e44d27a7..2695b8a7 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -18,6 +18,7 @@ import ( type NodeInput struct { Name string `json:"name"` IP string `json:"ip"` + IPManualOverride *bool `json:"ip_manual_override"` AutoUpdateEnabled bool `json:"auto_update_enabled"` GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` @@ -59,9 +60,11 @@ func CreateNode(input NodeInput) (*NodeView, error) { if name == "" { return nil, errors.New("节点名不能为空") } + ipManualOverride := resolveNodeIPManualOverride(input, nil, ip) node := &model.Node{ Name: name, IP: ip, + IPManualOverride: ipManualOverride, GeoName: geoName, GeoLatitude: geoLatitude, GeoLongitude: geoLongitude, @@ -102,8 +105,10 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) { if err != nil { return nil, err } + ipManualOverride := resolveNodeIPManualOverride(input, node, ip) node.Name = name node.IP = ip + node.IPManualOverride = ipManualOverride node.GeoName = geoName node.GeoLatitude = geoLatitude node.GeoLongitude = geoLongitude @@ -283,6 +288,7 @@ func buildNodeView(node *model.Node) *NodeView { NodeID: node.NodeID, Name: node.Name, IP: node.IP, + IPManualOverride: node.IPManualOverride, GeoName: strings.TrimSpace(node.GeoName), GeoLatitude: node.GeoLatitude, GeoLongitude: node.GeoLongitude, @@ -331,6 +337,9 @@ func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *flo if ip != "" && net.ParseIP(ip) == nil { return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效") } + if input.IPManualOverride != nil && *input.IPManualOverride && ip == "" { + return "", "", "", nil, nil, false, errors.New("锁定节点 IP 时必须填写节点 IP") + } if len(geoName) > 128 { return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符") } @@ -357,6 +366,19 @@ func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *flo return name, ip, geoName, geoLatitude, geoLongitude, true, nil } +func resolveNodeIPManualOverride(input NodeInput, existing *model.Node, normalizedIP string) bool { + if input.IPManualOverride != nil { + return *input.IPManualOverride + } + if existing == nil { + return strings.TrimSpace(normalizedIP) != "" + } + if existing.IPManualOverride { + return true + } + return strings.TrimSpace(normalizedIP) != "" && strings.TrimSpace(normalizedIP) != strings.TrimSpace(existing.IP) +} + func cloneCoordinate(value *float64) *float64 { if value == nil { return nil @@ -499,7 +521,9 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b node.Name = strings.TrimSpace(payload.Name) } } - node.IP = strings.TrimSpace(payload.IP) + if !node.IPManualOverride { + node.IP = strings.TrimSpace(payload.IP) + } node.AgentVersion = strings.TrimSpace(payload.AgentVersion) node.NginxVersion = strings.TrimSpace(payload.NginxVersion) node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus) diff --git a/openflare_server/service/node_update_test.go b/openflare_server/service/node_update_test.go index 690f0e06..e5f6b842 100644 --- a/openflare_server/service/node_update_test.go +++ b/openflare_server/service/node_update_test.go @@ -84,6 +84,10 @@ func geoipFloat(value float64) *float64 { return &value } +func boolValue(value bool) *bool { + return &value +} + func TestRequestNodeAgentPreviewUpdate(t *testing.T) { setupServiceTestDB(t) @@ -453,6 +457,192 @@ func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) { } } +func TestHeartbeatNodePreservesManualIPOverride(t *testing.T) { + setupServiceTestDB(t) + + node := &model.Node{ + NodeID: "node-ip-manual", + Name: "ip-manual", + IP: "203.0.113.10", + IPManualOverride: true, + AgentToken: "agent-token", + AgentVersion: "v0.4.0", + NginxVersion: "1.27.1.2", + Status: NodeStatusOnline, + } + if err := node.Insert(); err != nil { + t.Fatalf("failed to seed node: %v", err) + } + + resp, err := HeartbeatNode(node, AgentNodePayload{ + NodeID: node.NodeID, + Name: node.Name, + IP: "10.0.0.8", + AgentVersion: "v0.5.0", + NginxVersion: "1.27.1.3", + OpenrestyStatus: OpenrestyStatusHealthy, + }) + if err != nil { + t.Fatalf("expected heartbeat to succeed: %v", err) + } + if resp.Node.IP != "203.0.113.10" { + t.Fatalf("expected manual ip to be preserved, got %s", resp.Node.IP) + } + if resp.Node.AgentVersion != "v0.5.0" || resp.Node.NginxVersion != "1.27.1.3" { + t.Fatalf("expected runtime metadata to update despite locked ip, got %+v", resp.Node) + } + + stored, err := model.GetNodeByNodeID(node.NodeID) + if err != nil { + t.Fatalf("failed to reload node: %v", err) + } + if stored.IP != "203.0.113.10" { + t.Fatalf("expected stored manual ip to be preserved, got %s", stored.IP) + } +} + +func TestHeartbeatNodeUpdatesIPWhenManualOverrideDisabled(t *testing.T) { + setupServiceTestDB(t) + + node := &model.Node{ + NodeID: "node-ip-auto", + Name: "ip-auto", + IP: "10.0.0.8", + AgentToken: "agent-token", + AgentVersion: "v0.4.0", + NginxVersion: "1.27.1.2", + Status: NodeStatusOnline, + } + if err := node.Insert(); err != nil { + t.Fatalf("failed to seed node: %v", err) + } + + resp, err := HeartbeatNode(node, AgentNodePayload{ + NodeID: node.NodeID, + Name: node.Name, + IP: "8.8.8.8", + AgentVersion: node.AgentVersion, + NginxVersion: node.NginxVersion, + }) + if err != nil { + t.Fatalf("expected heartbeat to succeed: %v", err) + } + if resp.Node.IP != "8.8.8.8" { + t.Fatalf("expected heartbeat ip to update unlocked node, got %s", resp.Node.IP) + } +} + +func TestUpdateNodeCanLockAndUnlockManualIP(t *testing.T) { + setupServiceTestDB(t) + + node, err := CreateNode(NodeInput{Name: "manual-ip-edge"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + locked, err := UpdateNode(node.ID, NodeInput{ + Name: "manual-ip-edge", + IP: "203.0.113.10", + IPManualOverride: boolValue(true), + }) + if err != nil { + t.Fatalf("expected lock update to succeed: %v", err) + } + if !locked.IPManualOverride || locked.IP != "203.0.113.10" { + t.Fatalf("expected node ip to be locked, got %+v", locked) + } + + stored, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to reload node: %v", err) + } + if _, err = HeartbeatNode(stored, AgentNodePayload{ + NodeID: stored.NodeID, + Name: stored.Name, + IP: "8.8.8.8", + AgentVersion: "v0.5.0", + NginxVersion: "1.27.1.3", + }); err != nil { + t.Fatalf("expected heartbeat to succeed: %v", err) + } + + lockedStored, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to reload locked node: %v", err) + } + if lockedStored.IP != "203.0.113.10" { + t.Fatalf("expected heartbeat to preserve locked ip, got %s", lockedStored.IP) + } + + unlocked, err := UpdateNode(node.ID, NodeInput{ + Name: "manual-ip-edge", + IP: "203.0.113.10", + IPManualOverride: boolValue(false), + }) + if err != nil { + t.Fatalf("expected unlock update to succeed: %v", err) + } + if unlocked.IPManualOverride { + t.Fatalf("expected node ip lock to be disabled, got %+v", unlocked) + } + + unlockedStored, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to reload unlocked node: %v", err) + } + if _, err = HeartbeatNode(unlockedStored, AgentNodePayload{ + NodeID: unlockedStored.NodeID, + Name: unlockedStored.Name, + IP: "8.8.4.4", + AgentVersion: "v0.5.1", + NginxVersion: "1.27.1.4", + }); err != nil { + t.Fatalf("expected heartbeat to succeed after unlock: %v", err) + } + + reloaded, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to reload updated node: %v", err) + } + if reloaded.IP != "8.8.4.4" { + t.Fatalf("expected heartbeat to update unlocked ip, got %s", reloaded.IP) + } +} + +func TestNodeManualIPOverrideDefaultsForManualInput(t *testing.T) { + setupServiceTestDB(t) + + created, err := CreateNode(NodeInput{ + Name: "precreated-edge", + IP: "203.0.113.20", + }) + if err != nil { + t.Fatalf("expected create to succeed: %v", err) + } + if !created.IPManualOverride { + t.Fatalf("expected precreated node with explicit ip to default to manual override, got %+v", created) + } + + empty, err := CreateNode(NodeInput{Name: "auto-edge"}) + if err != nil { + t.Fatalf("expected empty-ip create to succeed: %v", err) + } + if empty.IPManualOverride { + t.Fatalf("expected empty-ip node to stay unlocked, got %+v", empty) + } + + updated, err := UpdateNode(empty.ID, NodeInput{ + Name: "auto-edge", + IP: "203.0.113.21", + }) + if err != nil { + t.Fatalf("expected update to succeed: %v", err) + } + if !updated.IPManualOverride { + t.Fatalf("expected manual ip edit to default to locked, got %+v", updated) + } +} + func TestRequestNodeOpenrestyRestart(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/web/features/nodes/components/node-detail-page.tsx b/openflare_server/web/features/nodes/components/node-detail-page.tsx index 628cb3ed..3d9008c1 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -1654,7 +1654,10 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {

IP 地址

-

{node.ip || '暂无'}

+

+ {node.ip || '暂无'} + {node.ip_manual_override ? '(管理端锁定)' : ''} +

diff --git a/openflare_server/web/features/nodes/components/node-editor-modal.tsx b/openflare_server/web/features/nodes/components/node-editor-modal.tsx index 29daf1ad..d2a6a85a 100644 --- a/openflare_server/web/features/nodes/components/node-editor-modal.tsx +++ b/openflare_server/web/features/nodes/components/node-editor-modal.tsx @@ -43,6 +43,7 @@ const nodeEditorSchema = z .min(1, '请输入节点名') .max(128, '节点名不能超过 128 个字符'), ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'), + ip_manual_override: z.boolean(), auto_update_enabled: z.boolean(), geo_manual_override: z.boolean(), geo_region: z.string(), @@ -51,6 +52,14 @@ const nodeEditorSchema = z geo_longitude: z.string().trim(), }) .superRefine((values, ctx) => { + if (values.ip_manual_override && values.ip.trim() === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['ip'], + message: '锁定节点 IP 时必须填写节点 IP', + }); + } + if (!values.geo_manual_override) { return; } @@ -100,6 +109,7 @@ type NodeEditorValues = z.infer; const defaultValues: NodeEditorValues = { name: '', ip: '', + ip_manual_override: false, auto_update_enabled: false, geo_manual_override: false, geo_region: '', @@ -184,6 +194,7 @@ function buildFormValues(node?: Partial | null): NodeEditorValues { return { name: node.name ?? '', ip: node.ip ?? '', + ip_manual_override: node.ip_manual_override ?? false, auto_update_enabled: node.auto_update_enabled ?? false, geo_manual_override: node.geo_manual_override ?? false, geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '', @@ -204,6 +215,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload { return { name: values.name.trim(), ip: values.ip.trim(), + ip_manual_override: values.ip_manual_override, auto_update_enabled: values.auto_update_enabled, geo_manual_override: false, geo_name: '', @@ -215,6 +227,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload { return { name: values.name.trim(), ip: values.ip.trim(), + ip_manual_override: values.ip_manual_override, auto_update_enabled: values.auto_update_enabled, geo_manual_override: true, geo_name: values.geo_name.trim(), @@ -253,6 +266,10 @@ export function NodeEditorModal({ control: form.control, name: 'auto_update_enabled', }); + const watchedIPManualOverride = useWatch({ + control: form.control, + name: 'ip_manual_override', + }); const watchedGeoManualOverride = useWatch({ control: form.control, name: 'geo_manual_override', @@ -266,6 +283,7 @@ export function NodeEditorModal({ form.reset(buildFormValues(node)); }, [form, isOpen, node]); + const ipField = form.register('ip'); const handleSubmit = form.handleSubmit((values) => { onSubmit(toPayload(values)); }); @@ -309,12 +327,42 @@ export function NodeEditorModal({ - + { + ipField.onChange(event); + const nextIP = event.target.value.trim(); + const previousIP = (node?.ip ?? '').trim(); + if (nextIP !== '' && nextIP !== previousIP) { + form.setValue('ip_manual_override', true, { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> + + form.setValue('ip_manual_override', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> +

IP:{node.ip || 'null'} + {node.ip_manual_override ? '(已锁定)' : ''}

位置:{node.geo_name || '未配置地图点位'} diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts index 4ad97ee9..7396f3d4 100644 --- a/openflare_server/web/features/nodes/types.ts +++ b/openflare_server/web/features/nodes/types.ts @@ -5,6 +5,7 @@ export interface NodeItem { node_id: string; name: string; ip: string; + ip_manual_override: boolean; geo_name: string; geo_latitude?: number | null; geo_longitude?: number | null; @@ -41,6 +42,7 @@ export interface NodeBootstrapToken { export interface NodeMutationPayload { name: string; ip: string; + ip_manual_override: boolean; auto_update_enabled: boolean; geo_name: string; geo_latitude?: number | null;