mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
[优化] 添加节点 IP 手动覆盖功能,更新相关文档和测试用例
This commit is contained in:
@@ -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` 是正式管理端前端:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent
|
||||
| 当前运行哪个版本 | 节点详情中的当前版本 |
|
||||
| 最近一次应用是否成功 | 应用记录 |
|
||||
|
||||
节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。
|
||||
|
||||
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
|
||||
|
||||
## 常见场景
|
||||
|
||||
@@ -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 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。
|
||||
|
||||
## 常见配置组合
|
||||
|
||||
|
||||
@@ -2994,6 +2994,9 @@ const docTemplate = `{
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip_manual_override": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -2991,6 +2991,9 @@
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip_manual_override": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3193,4 +3196,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,8 @@ definitions:
|
||||
type: string
|
||||
ip:
|
||||
type: string
|
||||
ip_manual_override:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 14
|
||||
currentDatabaseSchemaVersion = 15
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1654,7 +1654,10 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
IP 地址
|
||||
</p>
|
||||
<p className="mt-1">{node.ip || '暂无'}</p>
|
||||
<p className="mt-1">
|
||||
{node.ip || '暂无'}
|
||||
{node.ip_manual_override ? '(管理端锁定)' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
|
||||
@@ -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<typeof nodeEditorSchema>;
|
||||
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<NodeItem> | 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({
|
||||
|
||||
<ResourceField
|
||||
label="节点 IP"
|
||||
hint="可手动维护节点当前对外 IP;留空则等待 Agent 注册或心跳自动回填。"
|
||||
hint={
|
||||
watchedIPManualOverride
|
||||
? '已锁定为指定值,不会被更新。'
|
||||
: '留空或关闭锁定时,等待 Agent 自动更新。'
|
||||
}
|
||||
error={form.formState.errors.ip?.message}
|
||||
>
|
||||
<ResourceInput placeholder="203.0.113.10" {...form.register('ip')} />
|
||||
<ResourceInput
|
||||
placeholder="203.0.113.10"
|
||||
{...ipField}
|
||||
onChange={(event) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="锁定节点 IP"
|
||||
description="开启后以管理端填写的 IP 为准,Agent 后续上报不会覆盖该字段。"
|
||||
checked={watchedIPManualOverride}
|
||||
onChange={(checked) =>
|
||||
form.setValue('ip_manual_override', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
label="启用自动更新"
|
||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||
|
||||
@@ -309,6 +309,7 @@ export function NodesPage() {
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user