mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 23:16:37 +08:00
[优化] 添加节点 IP 手动覆盖功能,更新相关文档和测试用例
This commit is contained in:
@@ -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