diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index f78912a4..977bc1e3 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -53,7 +53,7 @@ var NodeOfflineThreshold = 2 * time.Minute // V3 operational settings (hot-reloadable via Option table) var AgentHeartbeatInterval = 10000 // milliseconds var AgentUpdateRepo = "Rain-kl/ATSFlare" -var GeoIPProvider = "disabled" +var GeoIPProvider = "ipinfo" // V5 OpenResty performance settings (hot-reloadable via Option table) var OpenRestyWorkerProcesses = "auto" diff --git a/atsf_server/model/node.go b/atsf_server/model/node.go index 0d9abb4b..0c869b6a 100644 --- a/atsf_server/model/node.go +++ b/atsf_server/model/node.go @@ -10,6 +10,7 @@ type Node struct { GeoName string `json:"geo_name" gorm:"size:128"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` + GeoManualOverride bool `json:"geo_manual_override" gorm:"not null;default:false"` AgentToken string `json:"-" gorm:"size:128;index"` AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"` UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"` diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index 98f137ce..1758cd07 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -184,10 +184,11 @@ func TestPhase2AgentLifecycle(t *testing.T) { } createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{ - "name": "shanghai-edge-1", - "geo_name": "Shanghai", - "geo_latitude": 31.2304, - "geo_longitude": 121.4737, + "name": "shanghai-edge-1", + "geo_manual_override": true, + "geo_name": "Shanghai", + "geo_latitude": 31.2304, + "geo_longitude": 121.4737, }) var createdNode service.NodeView decodeResponseData(t, createdNodeResp, &createdNode) @@ -330,10 +331,11 @@ func TestPhase2AgentLifecycle(t *testing.T) { } updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{ - "name": "shanghai-edge-1-renamed", - "geo_name": "Tokyo", - "geo_latitude": 35.6762, - "geo_longitude": 139.6503, + "name": "shanghai-edge-1-renamed", + "geo_manual_override": true, + "geo_name": "Tokyo", + "geo_latitude": 35.6762, + "geo_longitude": 139.6503, }) decodeResponseData(t, updatedNodeResp, &createdNode) if createdNode.Name != "shanghai-edge-1-renamed" { diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index e0ed2a5a..12df0937 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -89,6 +89,7 @@ type NodeView struct { GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` + GeoManualOverride bool `json:"geo_manual_override"` AgentToken string `json:"agent_token"` AutoUpdateEnabled bool `json:"auto_update_enabled"` UpdateRequested bool `json:"update_requested"` @@ -324,6 +325,7 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[ } appendIfChanged("name", previous.Name, current.Name) appendIfChanged("ip", previous.IP, current.IP) + appendIfChanged("geo_name", previous.GeoName, current.GeoName) appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion) appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion) appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus) @@ -335,8 +337,21 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[ appendIfChanged("update_channel", previous.UpdateChannel, current.UpdateChannel) appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag) appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested) + if !coordinatesEqual(previous.GeoLatitude, current.GeoLatitude) { + changes["geo_latitude"] = current.GeoLatitude + } + if !coordinatesEqual(previous.GeoLongitude, current.GeoLongitude) { + changes["geo_longitude"] = current.GeoLongitude + } if !previous.LastSeenAt.Equal(current.LastSeenAt) { changes["last_seen_at"] = current.LastSeenAt } return changes } + +func coordinatesEqual(before *float64, after *float64) bool { + if before == nil || after == nil { + return before == after + } + return *before == *after +} diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go index ceab1264..05c39859 100644 --- a/atsf_server/service/node.go +++ b/atsf_server/service/node.go @@ -3,21 +3,25 @@ package service import ( "atsflare/common" "atsflare/model" + "atsflare/utils/geoip" "context" "crypto/rand" "encoding/hex" "errors" "log/slog" + "net" "strings" "time" ) type NodeInput struct { Name string `json:"name"` + IP string `json:"ip"` AutoUpdateEnabled bool `json:"auto_update_enabled"` GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` + GeoManualOverride bool `json:"geo_manual_override"` } type NodeAgentUpdateInput struct { @@ -50,16 +54,17 @@ type AgentRegistrationResponse struct { } func CreateNode(input NodeInput) (*NodeView, error) { - name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input) + name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) if name == "" { return nil, errors.New("节点名不能为空") } node := &model.Node{ Name: name, - IP: "", + IP: ip, GeoName: geoName, GeoLatitude: geoLatitude, GeoLongitude: geoLongitude, + GeoManualOverride: geoManualOverride, AgentVersion: "", NginxVersion: "", Status: NodeStatusPending, @@ -73,6 +78,9 @@ func CreateNode(input NodeInput) (*NodeView, error) { if err != nil { return nil, err } + if !node.GeoManualOverride { + applyGeoInfoFromIP(node, node.IP) + } if err := node.Insert(); err != nil { if isUniqueConstraintError(err) { return nil, errors.New("节点标识生成冲突,请重试") @@ -84,7 +92,7 @@ func CreateNode(input NodeInput) (*NodeView, error) { } func UpdateNode(id uint, input NodeInput) (*NodeView, error) { - name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input) + name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) if name == "" { return nil, errors.New("节点名不能为空") } @@ -93,10 +101,15 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) { return nil, err } node.Name = name + node.IP = ip node.GeoName = geoName node.GeoLatitude = geoLatitude node.GeoLongitude = geoLongitude + node.GeoManualOverride = geoManualOverride node.AutoUpdateEnabled = input.AutoUpdateEnabled + if !node.GeoManualOverride { + applyGeoInfoFromIP(node, strings.TrimSpace(node.IP)) + } if err = node.Update(); err != nil { return nil, err } @@ -242,6 +255,7 @@ func buildNodeView(node *model.Node) *NodeView { GeoName: strings.TrimSpace(node.GeoName), GeoLatitude: node.GeoLatitude, GeoLongitude: node.GeoLongitude, + GeoManualOverride: node.GeoManualOverride, AgentToken: node.AgentToken, UpdateChannel: strings.TrimSpace(node.UpdateChannel), UpdateTag: strings.TrimSpace(node.UpdateTag), @@ -265,26 +279,41 @@ func buildNodeView(node *model.Node) *NodeView { return view } -func normalizeNodeInput(input NodeInput) (string, string, *float64, *float64, error) { +func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *float64, bool, error) { name := strings.TrimSpace(input.Name) + ip := strings.TrimSpace(input.IP) geoName := strings.TrimSpace(input.GeoName) + manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil + if len(ip) > 64 { + return "", "", "", nil, nil, false, errors.New("节点 IP 不能超过 64 个字符") + } + if ip != "" && net.ParseIP(ip) == nil { + return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效") + } if len(geoName) > 128 { - return "", "", nil, nil, errors.New("节点位置名不能超过 128 个字符") + return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符") } geoLatitude := cloneCoordinate(input.GeoLatitude) geoLongitude := cloneCoordinate(input.GeoLongitude) if (geoLatitude == nil) != (geoLongitude == nil) { - return "", "", nil, nil, errors.New("地图坐标必须同时填写纬度和经度") + return "", "", "", nil, nil, false, errors.New("地图坐标必须同时填写纬度和经度") } if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) { - return "", "", nil, nil, errors.New("纬度必须在 -90 到 90 之间") + return "", "", "", nil, nil, false, errors.New("纬度必须在 -90 到 90 之间") } if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) { - return "", "", nil, nil, errors.New("经度必须在 -180 到 180 之间") + return "", "", "", nil, nil, false, errors.New("经度必须在 -180 到 180 之间") } - return name, geoName, geoLatitude, geoLongitude, nil + if !manualOverride { + return name, ip, "", nil, nil, false, nil + } + if geoLatitude == nil && geoLongitude == nil && geoName == "" { + return name, ip, "", nil, nil, false, nil + } + + return name, ip, geoName, geoLatitude, geoLongitude, true, nil } func cloneCoordinate(value *float64) *float64 { @@ -410,6 +439,33 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) node.LastSeenAt = time.Now() node.LastError = strings.TrimSpace(payload.LastError) + if !node.GeoManualOverride { + applyGeoInfoFromIP(node, node.IP) + } +} + +func applyGeoInfoFromIP(node *model.Node, rawIP string) { + if node == nil { + return + } + node.GeoName = "" + node.GeoLatitude = nil + node.GeoLongitude = nil + ip := net.ParseIP(strings.TrimSpace(rawIP)) + if ip == nil { + return + } + info, err := geoip.GetGeoInfo(ip) + if err != nil || info == nil { + return + } + if strings.TrimSpace(info.Name) != "" { + node.GeoName = strings.TrimSpace(info.Name) + } + if info.Latitude != nil && info.Longitude != nil { + node.GeoLatitude = cloneCoordinate(info.Latitude) + node.GeoLongitude = cloneCoordinate(info.Longitude) + } } func normalizeOpenrestyStatus(status string) string { diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index 16d2c0ee..f4f96c89 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -3,7 +3,9 @@ package service import ( "atsflare/common" "atsflare/model" + "atsflare/utils/geoip" "io" + "net" "net/http" "sort" "strings" @@ -13,20 +15,54 @@ import ( type roundTripFunc func(req *http.Request) (*http.Response, error) +type fakeGeoIPProvider struct { + info *geoip.GeoInfo +} + func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func (f *fakeGeoIPProvider) Name() string { + return "fake-geoip" +} + +func (f *fakeGeoIPProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) { + return f.info, nil +} + +func (f *fakeGeoIPProvider) UpdateDatabase() error { + return nil +} + +func (f *fakeGeoIPProvider) Close() error { + return nil +} + +func withFakeGeoIPProvider(t *testing.T, info *geoip.GeoInfo) { + t.Helper() + previous := geoip.CurrentProvider + geoip.CurrentProvider = &fakeGeoIPProvider{info: info} + t.Cleanup(func() { + geoip.CurrentProvider = previous + }) +} + +func geoipFloat(value float64) *float64 { + return &value +} + func TestRequestNodeAgentPreviewUpdate(t *testing.T) { setupServiceTestDB(t) latitude := 31.2304 longitude := 121.4737 node, err := CreateNode(NodeInput{ - Name: "preview-edge-1", - GeoName: "Shanghai", - GeoLatitude: &latitude, - GeoLongitude: &longitude, + Name: "preview-edge-1", + GeoManualOverride: true, + GeoName: "Shanghai", + GeoLatitude: &latitude, + GeoLongitude: &longitude, }) if err != nil { t.Fatalf("failed to create node: %v", err) @@ -173,6 +209,7 @@ func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) { updated, err := UpdateNode(node.ID, NodeInput{ Name: "geo-edge-updated", AutoUpdateEnabled: true, + GeoManualOverride: true, GeoName: "San Francisco", GeoLatitude: &latitude, GeoLongitude: &longitude, @@ -203,13 +240,148 @@ func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) { latitude := 37.7749 if _, err = UpdateNode(node.ID, NodeInput{ - Name: "geo-edge-invalid", - GeoLatitude: &latitude, + Name: "geo-edge-invalid", + GeoManualOverride: true, + GeoLatitude: &latitude, }); err == nil { t.Fatal("expected partial geo metadata to be rejected") } } +func TestUpdateNodeRejectsInvalidIP(t *testing.T) { + setupServiceTestDB(t) + + node, err := CreateNode(NodeInput{Name: "geo-edge-invalid-ip"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + if _, err = UpdateNode(node.ID, NodeInput{ + Name: "geo-edge-invalid-ip", + IP: "not-an-ip", + }); err == nil { + t.Fatal("expected invalid IP to be rejected") + } +} + +func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) { + setupServiceTestDB(t) + withFakeGeoIPProvider(t, &geoip.GeoInfo{ + ISOCode: "US", + Name: "United States", + Latitude: geoipFloat(37.7749), + Longitude: geoipFloat(-122.4194), + }) + + node, err := CreateNode(NodeInput{Name: "geo-edge-auto-ip"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + updated, err := UpdateNode(node.ID, NodeInput{ + Name: "geo-edge-auto-ip", + IP: "8.8.8.8", + }) + if err != nil { + t.Fatalf("expected node update to succeed: %v", err) + } + if updated.IP != "8.8.8.8" { + t.Fatalf("expected updated IP to be persisted, got %+v", updated.IP) + } + if updated.GeoName != "United States" { + t.Fatalf("expected geo name to be auto resolved, got %+v", updated) + } + if updated.GeoLatitude == nil || updated.GeoLongitude == nil { + t.Fatalf("expected geo coordinates to be auto resolved, got %+v", updated) + } +} + +func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) { + setupServiceTestDB(t) + withFakeGeoIPProvider(t, &geoip.GeoInfo{ + ISOCode: "US", + Name: "United States", + Latitude: geoipFloat(37.7749), + Longitude: geoipFloat(-122.4194), + }) + + node := &model.Node{ + NodeID: "node-geo-auto", + Name: "geo-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.GeoName != "United States" { + t.Fatalf("expected auto geo name, got %+v", resp.Node) + } + if resp.Node.GeoLatitude == nil || resp.Node.GeoLongitude == nil { + t.Fatalf("expected auto geo coordinates, got %+v", resp.Node) + } +} + +func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) { + setupServiceTestDB(t) + withFakeGeoIPProvider(t, &geoip.GeoInfo{ + ISOCode: "US", + Name: "United States", + Latitude: geoipFloat(37.7749), + Longitude: geoipFloat(-122.4194), + }) + + latitude := 31.2304 + longitude := 121.4737 + node := &model.Node{ + NodeID: "node-geo-manual", + Name: "geo-manual", + IP: "10.0.0.8", + GeoName: "Shanghai", + GeoLatitude: &latitude, + GeoLongitude: &longitude, + GeoManualOverride: 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: "8.8.8.8", + AgentVersion: node.AgentVersion, + NginxVersion: node.NginxVersion, + }) + if err != nil { + t.Fatalf("expected heartbeat to succeed: %v", err) + } + if resp.Node.GeoName != "Shanghai" { + t.Fatalf("expected manual geo name to be preserved, got %+v", resp.Node) + } + if resp.Node.GeoLatitude == nil || *resp.Node.GeoLatitude != latitude { + t.Fatalf("expected manual latitude to be preserved, got %+v", resp.Node.GeoLatitude) + } +} + func TestRequestNodeOpenrestyRestart(t *testing.T) { setupServiceTestDB(t) diff --git a/atsf_server/utils/geoip/geoip.go b/atsf_server/utils/geoip/geoip.go index 65d9b39d..fd16f65d 100644 --- a/atsf_server/utils/geoip/geoip.go +++ b/atsf_server/utils/geoip/geoip.go @@ -24,8 +24,10 @@ const ( ) type GeoInfo struct { - ISOCode string - Name string + ISOCode string + Name string + Latitude *float64 + Longitude *float64 } func init() { @@ -202,3 +204,7 @@ func getProvider() GeoIPService { } return CurrentProvider } + +func float64Pointer(value float64) *float64 { + return &value +} diff --git a/atsf_server/utils/geoip/geojs.go b/atsf_server/utils/geoip/geojs.go index 769182c9..c3de263b 100644 --- a/atsf_server/utils/geoip/geojs.go +++ b/atsf_server/utils/geoip/geojs.go @@ -16,8 +16,10 @@ type GeoJSService struct { // geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。 // 我们只定义我们需要的字段。 type geoJSResponse struct { - Country string `json:"country"` - CountryCode string `json:"country_code"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + Latitude float64 `json:"latitude,string"` + Longitude float64 `json:"longitude,string"` // 可以根据需要添加其他字段,例如: // City string `json:"city"` // Region string `json:"region"` @@ -64,8 +66,10 @@ func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) { } return &GeoInfo{ - ISOCode: apiResp.CountryCode, - Name: apiResp.Country, + ISOCode: apiResp.CountryCode, + Name: apiResp.Country, + Latitude: float64Pointer(apiResp.Latitude), + Longitude: float64Pointer(apiResp.Longitude), }, nil } diff --git a/atsf_server/utils/geoip/ipapi.go b/atsf_server/utils/geoip/ipapi.go index 08fb139d..afbf4412 100644 --- a/atsf_server/utils/geoip/ipapi.go +++ b/atsf_server/utils/geoip/ipapi.go @@ -66,8 +66,10 @@ func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) { } return &GeoInfo{ - ISOCode: apiResp.CountryCode, - Name: apiResp.Country, + ISOCode: apiResp.CountryCode, + Name: apiResp.Country, + Latitude: float64Pointer(apiResp.Lat), + Longitude: float64Pointer(apiResp.Lon), }, nil } diff --git a/atsf_server/utils/geoip/ipinfo.go b/atsf_server/utils/geoip/ipinfo.go index 0f1d32ed..416ce3d1 100644 --- a/atsf_server/utils/geoip/ipinfo.go +++ b/atsf_server/utils/geoip/ipinfo.go @@ -5,6 +5,8 @@ import ( "fmt" "net" "net/http" + "strconv" + "strings" "time" ) @@ -65,6 +67,8 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) { return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err) } + latitude, longitude := parseIPInfoCoordinates(apiResp.Loc) + // IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN" // 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。 // IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。 @@ -73,8 +77,10 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) { // 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。 // 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。 return &GeoInfo{ - ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code - Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称 + ISOCode: apiResp.Country, + Name: apiResp.Country, + Latitude: latitude, + Longitude: longitude, }, nil } @@ -89,3 +95,18 @@ func (s *IPInfoService) Close() error { // 无需执行任何操作 return nil } + +func parseIPInfoCoordinates(value string) (*float64, *float64) { + parts := strings.Split(strings.TrimSpace(value), ",") + if len(parts) != 2 { + return nil, nil + } + + latitudeValue, latErr := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + longitudeValue, lonErr := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if latErr != nil || lonErr != nil { + return nil, nil + } + + return float64Pointer(latitudeValue), float64Pointer(longitudeValue) +} diff --git a/atsf_server/web/features/dashboard/components/world-stage-map.tsx b/atsf_server/web/features/dashboard/components/world-stage-map.tsx new file mode 100644 index 00000000..994d47c9 --- /dev/null +++ b/atsf_server/web/features/dashboard/components/world-stage-map.tsx @@ -0,0 +1,372 @@ +'use client'; + +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import type { EChartsCoreOption } from 'echarts/core'; +import * as echarts from 'echarts/core'; +import { ScatterChart } from 'echarts/charts'; +import { GeoComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import worldGeoJson from '@/features/dashboard/data/world-geo.json'; +import type { DashboardNodeHealth } from '@/features/dashboard/types'; +import { + getNodeStatusLabel, + getOpenrestyStatusLabel, +} from '@/features/nodes/utils'; + +echarts.use([GeoComponent, TooltipComponent, ScatterChart, CanvasRenderer]); + +const fallbackCoordinates = [ + [-122.4194, 37.7749], + [-46.6333, -23.5505], + [-0.1276, 51.5072], + [2.3522, 48.8566], + [77.209, 28.6139], + [121.4737, 31.2304], + [103.8198, 1.3521], + [151.2093, -33.8688], + [28.0473, -26.2041], + [139.6917, 35.6895], +] as const; + +type Tone = 'healthy' | 'warning' | 'danger'; + +type WorldGeoJson = { + type: string; + features?: unknown[]; +}; + +type MapNodeDatum = { + id: number; + name: string; + geoName: string; + route: string; + derivedFromGeo: boolean; + requestCount: number; + errorCount: number; + activeEventCount: number; + status: DashboardNodeHealth['status']; + openrestyStatus: DashboardNodeHealth['openresty_status']; + value: [number, number, number]; + itemStyle: { + color: string; + borderColor: string; + borderWidth: number; + }; + emphasis: { + itemStyle: { + borderColor: string; + borderWidth: number; + }; + }; +}; + +let worldMapRegistrationAttempted = false; +let worldMapRegistrationSucceeded = false; + +function buildNodeDetailHref(id?: number | null) { + if (!id) { + return '/node'; + } + return `/node/detail?id=${id}`; +} + +function getNodeTone(node: DashboardNodeHealth): Tone { + if ( + node.status === 'offline' || + node.openresty_status === 'unhealthy' || + node.active_event_count > 0 + ) { + return 'danger'; + } + + if ( + node.cpu_usage_percent >= 80 || + node.memory_usage_percent >= 85 || + node.storage_usage_percent >= 85 + ) { + return 'warning'; + } + + return 'healthy'; +} + +function getNodeCoordinates(node: DashboardNodeHealth, index: number) { + if ( + typeof node.geo_latitude === 'number' && + typeof node.geo_longitude === 'number' + ) { + return { + coordinates: [node.geo_longitude, node.geo_latitude] as [number, number], + derivedFromGeo: true, + }; + } + + return { + coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [ + number, + number, + ], + derivedFromGeo: false, + }; +} + +function ensureWorldMapRegistered() { + if (worldMapRegistrationSucceeded || echarts.getMap('world')) { + worldMapRegistrationSucceeded = true; + return true; + } + + if (worldMapRegistrationAttempted) { + return false; + } + + worldMapRegistrationAttempted = true; + + try { + const geoJson = worldGeoJson as WorldGeoJson; + + if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) { + throw new Error('invalid world geojson payload'); + } + + echarts.registerMap( + 'world', + geoJson as unknown as Parameters[1], + ); + + if (!echarts.getMap('world')) { + throw new Error('world map registration failed'); + } + + worldMapRegistrationSucceeded = true; + return true; + } catch (error) { + const registrationError = + error instanceof Error ? error : new Error('unknown world map registration error'); + console.error('Failed to register ECharts world map', registrationError); + return false; + } +} + +export function WorldStageMap({ + isDark, + nodes, +}: { + isDark: boolean; + nodes: DashboardNodeHealth[]; +}) { + const router = useRouter(); + const [mapReady, setMapReady] = useState(false); + const [mapFailed, setMapFailed] = useState(false); + + useEffect(() => { + const ready = ensureWorldMapRegistered(); + setMapReady(ready); + setMapFailed(!ready); + }, []); + + const mapPalette = useMemo( + () => + isDark + ? { + areaColor: '#13233b', + borderColor: 'rgba(125,211,252,0.14)', + labelColor: '#e2e8f0', + healthyColor: '#34d399', + warningColor: '#fbbf24', + dangerColor: '#fb7185', + healthyBorder: '#bbf7d0', + warningBorder: '#fde68a', + dangerBorder: '#fecdd3', + } + : { + areaColor: '#eaf2ff', + borderColor: 'rgba(71,85,105,0.12)', + labelColor: '#0f172a', + healthyColor: '#10b981', + warningColor: '#f59e0b', + dangerColor: '#f43f5e', + healthyBorder: '#d1fae5', + warningBorder: '#fde68a', + dangerBorder: '#fecdd3', + }, + [isDark], + ); + + const mapNodes = useMemo( + () => + nodes.map((node, index) => { + const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index); + const tone = getNodeTone(node); + const toneColor = + tone === 'healthy' + ? mapPalette.healthyColor + : tone === 'warning' + ? mapPalette.warningColor + : mapPalette.dangerColor; + const toneBorder = + tone === 'healthy' + ? mapPalette.healthyBorder + : tone === 'warning' + ? mapPalette.warningBorder + : mapPalette.dangerBorder; + + return { + id: node.id, + name: node.name, + geoName: node.geo_name || node.name, + route: buildNodeDetailHref(node.id), + derivedFromGeo, + requestCount: node.request_count, + errorCount: node.error_count, + activeEventCount: node.active_event_count, + status: node.status, + openrestyStatus: node.openresty_status, + value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)], + itemStyle: { + color: toneColor, + borderColor: toneBorder, + borderWidth: 1.5, + }, + emphasis: { + itemStyle: { + borderColor: toneBorder, + borderWidth: 2, + }, + }, + }; + }), + [mapPalette, nodes], + ); + + const mapOption = useMemo( + () => ({ + animation: false, + backgroundColor: 'transparent', + tooltip: { + trigger: 'item', + transitionDuration: 0, + backgroundColor: isDark + ? 'rgba(15,23,42,0.96)' + : 'rgba(255,255,255,0.96)', + borderColor: isDark + ? 'rgba(148,163,184,0.2)' + : 'rgba(148,163,184,0.24)', + borderWidth: 1, + textStyle: { + color: isDark ? '#e2e8f0' : '#0f172a', + fontSize: 12, + }, + formatter: (params: unknown) => { + const data = (params as { data?: MapNodeDatum }).data; + if (!data) { + return ''; + } + + const locationLine = data.derivedFromGeo + ? data.geoName + : `${data.geoName} · 预设落点`; + + return [ + `
${data.name}
`, + `
${locationLine}
`, + `
请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}
`, + `
活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}
`, + `
OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}
`, + ].join(''); + }, + }, + geo: { + map: 'world', + roam: false, + silent: true, + layoutCenter: ['50%', '50%'], + layoutSize: '132%', + zoom: 1.12, + itemStyle: { + areaColor: mapPalette.areaColor, + borderColor: mapPalette.borderColor, + borderWidth: 0.7, + }, + emphasis: { + disabled: true, + }, + }, + series: [ + { + type: 'scatter', + coordinateSystem: 'geo', + data: mapNodes, + progressive: 64, + large: true, + largeThreshold: 24, + symbolSize: (value: unknown) => { + const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1; + return Math.max(8, Math.min(18, 8 + Math.log10(size + 1) * 3.6)); + }, + label: { + show: false, + color: mapPalette.labelColor, + fontSize: 11, + }, + emphasis: { + scale: 1.12, + label: { + show: mapNodes.length > 0 && mapNodes.length <= 5, + position: 'right', + distance: 8, + formatter: '{b}', + backgroundColor: isDark + ? 'rgba(8,15,31,0.7)' + : 'rgba(255,255,255,0.88)', + borderColor: isDark + ? 'rgba(148,163,184,0.16)' + : 'rgba(148,163,184,0.2)', + borderWidth: 1, + borderRadius: 999, + padding: [4, 8], + }, + }, + }, + ], + }), + [isDark, mapNodes, mapPalette], + ); + + if (!mapReady) { + return ( +
+ +
+ ); + } + + return ( + { + if (params.data?.route) { + router.push(params.data.route); + } + }, + }} + style={{ height: '100%', width: '100%' }} + /> + ); +} diff --git a/atsf_server/web/features/dashboard/components/world-stage.tsx b/atsf_server/web/features/dashboard/components/world-stage.tsx index 3d687533..79cb323f 100644 --- a/atsf_server/web/features/dashboard/components/world-stage.tsx +++ b/atsf_server/web/features/dashboard/components/world-stage.tsx @@ -1,15 +1,11 @@ 'use client'; -import * as echarts from 'echarts'; -import type { EChartsOption } from 'echarts'; -import ReactECharts from 'echarts-for-react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { useEffect, useRef, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { useTheme } from '@/components/providers/theme-provider'; -import worldGeoJson from '@/features/dashboard/data/world-geo.json'; import { StatusBadge } from '@/components/ui/status-badge'; import type { DashboardConfig, @@ -27,100 +23,15 @@ import { import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; -const fallbackCoordinates = [ - [-122.4194, 37.7749], - [-46.6333, -23.5505], - [-0.1276, 51.5072], - [2.3522, 48.8566], - [77.209, 28.6139], - [121.4737, 31.2304], - [103.8198, 1.3521], - [151.2093, -33.8688], - [28.0473, -26.2041], - [139.6917, 35.6895], -] as const; - type Tone = 'healthy' | 'warning' | 'danger'; -type MapNodeDatum = { - id: number; - name: string; - geoName: string; - route: string; - derivedFromGeo: boolean; - requestCount: number; - errorCount: number; - activeEventCount: number; - status: DashboardNodeHealth['status']; - openrestyStatus: DashboardNodeHealth['openresty_status']; - tone: Tone; - value: [number, number, number]; - itemStyle: { - color: string; - borderColor: string; - borderWidth: number; - shadowBlur: number; - shadowColor: string; - }; - emphasis: { - itemStyle: { - shadowBlur: number; - shadowColor: string; - borderColor: string; - borderWidth: number; - }; - }; -}; - -type WorldGeoJson = { - type: string; - features?: unknown[]; -}; - -let worldMapRegistrationAttempted = false; -let worldMapRegistrationSucceeded = false; -let worldMapRegistrationError: Error | null = null; - -function ensureWorldMapRegistered() { - if (worldMapRegistrationSucceeded || echarts.getMap('world')) { - worldMapRegistrationSucceeded = true; - worldMapRegistrationError = null; - return true; - } - - if (worldMapRegistrationAttempted) { - return false; - } - - worldMapRegistrationAttempted = true; - - try { - const geoJson = worldGeoJson as WorldGeoJson; - - if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) { - throw new Error('invalid world geojson payload'); - } - - echarts.registerMap( - 'world', - geoJson as unknown as Parameters[1], - ); - - if (!echarts.getMap('world')) { - throw new Error('world map registration failed'); - } - - worldMapRegistrationSucceeded = true; - worldMapRegistrationError = null; - return true; - } catch (error) { - worldMapRegistrationSucceeded = false; - worldMapRegistrationError = - error instanceof Error ? error : new Error('unknown world map registration error'); - console.error('Failed to register ECharts world map', worldMapRegistrationError); - return false; - } -} +const WorldStageMap = dynamic( + () => + import('@/features/dashboard/components/world-stage-map').then( + (module) => module.WorldStageMap, + ), + { ssr: false }, +); function formatPercent(value: number) { if (!Number.isFinite(value)) { @@ -136,46 +47,6 @@ function buildNodeDetailHref(id?: number | null) { return `/node/detail?id=${id}`; } -function getNodeTone(node: DashboardNodeHealth): Tone { - if ( - node.status === 'offline' || - node.openresty_status === 'unhealthy' || - node.active_event_count > 0 - ) { - return 'danger'; - } - - if ( - node.cpu_usage_percent >= 80 || - node.memory_usage_percent >= 85 || - node.storage_usage_percent >= 85 - ) { - return 'warning'; - } - - return 'healthy'; -} - -function getNodeCoordinates(node: DashboardNodeHealth, index: number) { - if ( - typeof node.geo_latitude === 'number' && - typeof node.geo_longitude === 'number' - ) { - return { - coordinates: [node.geo_longitude, node.geo_latitude] as [number, number], - derivedFromGeo: true, - }; - } - - return { - coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [ - number, - number, - ], - derivedFromGeo: false, - }; -} - function HeroMetric({ label, value, @@ -190,7 +61,7 @@ function HeroMetric({ return (

@@ -315,17 +186,38 @@ export function WorldStage({ nodes: DashboardNodeHealth[]; sourceCountries: DistributionItem[]; }) { - const router = useRouter(); const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === 'dark'; - const [mapReady, setMapReady] = useState(false); - const [mapFailed, setMapFailed] = useState(false); + const mapViewportRef = useRef(null); + const [shouldRenderMap, setShouldRenderMap] = useState(false); useEffect(() => { - const ready = ensureWorldMapRegistered(); - setMapReady(ready); - setMapFailed(!ready); - }, []); + if (shouldRenderMap) { + return; + } + + const mapViewport = mapViewportRef.current; + if (!mapViewport || typeof IntersectionObserver === 'undefined') { + setShouldRenderMap(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShouldRenderMap(true); + observer.disconnect(); + } + }, + { rootMargin: '180px 0px' }, + ); + + observer.observe(mapViewport); + + return () => { + observer.disconnect(); + }; + }, [shouldRenderMap]); const onlineRate = summary.total_nodes > 0 @@ -349,185 +241,6 @@ export function WorldStage({ typeof node.geo_longitude === 'number', ).length; - const mapPalette = useMemo( - () => - isDark - ? { - areaColor: '#13233b', - borderColor: 'rgba(125,211,252,0.18)', - shadowColor: 'rgba(8,15,31,0.35)', - labelColor: '#e2e8f0', - subLabelColor: '#94a3b8', - healthyColor: '#34d399', - warningColor: '#fbbf24', - dangerColor: '#fb7185', - healthyBorder: '#bbf7d0', - warningBorder: '#fde68a', - dangerBorder: '#fecdd3', - } - : { - areaColor: '#eaf2ff', - borderColor: 'rgba(71,85,105,0.16)', - shadowColor: 'rgba(148,163,184,0.18)', - labelColor: '#0f172a', - subLabelColor: '#475569', - healthyColor: '#10b981', - warningColor: '#f59e0b', - dangerColor: '#f43f5e', - healthyBorder: '#d1fae5', - warningBorder: '#fde68a', - dangerBorder: '#fecdd3', - }, - [isDark], - ); - - const mapNodes = useMemo( - () => - nodes.map((node, index) => { - const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index); - const tone = getNodeTone(node); - const toneColor = - tone === 'healthy' - ? mapPalette.healthyColor - : tone === 'warning' - ? mapPalette.warningColor - : mapPalette.dangerColor; - const toneBorder = - tone === 'healthy' - ? mapPalette.healthyBorder - : tone === 'warning' - ? mapPalette.warningBorder - : mapPalette.dangerBorder; - - return { - id: node.id, - name: node.name, - geoName: node.geo_name || node.name, - route: buildNodeDetailHref(node.id), - derivedFromGeo, - requestCount: node.request_count, - errorCount: node.error_count, - activeEventCount: node.active_event_count, - status: node.status, - openrestyStatus: node.openresty_status, - tone, - value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)], - itemStyle: { - color: toneColor, - borderColor: toneBorder, - borderWidth: 2, - shadowBlur: 18, - shadowColor: toneColor, - }, - emphasis: { - itemStyle: { - shadowBlur: 24, - shadowColor: toneColor, - borderColor: toneBorder, - borderWidth: 2, - }, - }, - }; - }), - [mapPalette, nodes], - ); - - const mapOption = useMemo( - () => ({ - backgroundColor: 'transparent', - tooltip: { - trigger: 'item', - backgroundColor: isDark - ? 'rgba(15,23,42,0.96)' - : 'rgba(255,255,255,0.96)', - borderColor: isDark - ? 'rgba(148,163,184,0.2)' - : 'rgba(148,163,184,0.24)', - borderWidth: 1, - textStyle: { - color: isDark ? '#e2e8f0' : '#0f172a', - fontSize: 12, - }, - formatter: (params: unknown) => { - const data = (params as { data?: MapNodeDatum }).data; - if (!data) { - return ''; - } - - const locationLine = data.derivedFromGeo - ? data.geoName - : `${data.geoName} · 备用落点`; - - return [ - `

${data.name}
`, - `
${locationLine}
`, - `
请求 ${data.requestCount.toLocaleString('zh-CN')} · 错误 ${data.errorCount.toLocaleString('zh-CN')}
`, - `
活动异常 ${data.activeEventCount} · 节点 ${getNodeStatusLabel(data.status)}
`, - `
OpenResty ${getOpenrestyStatusLabel(data.openrestyStatus)}
`, - ].join(''); - }, - }, - geo: { - map: 'world', - roam: false, - silent: true, - regions: [], - top: 18, - bottom: 18, - left: 10, - right: 10, - itemStyle: { - areaColor: mapPalette.areaColor, - borderColor: mapPalette.borderColor, - borderWidth: 0.8, - shadowBlur: 12, - shadowColor: mapPalette.shadowColor, - }, - emphasis: { - disabled: true, - }, - }, - series: [ - { - type: 'effectScatter', - coordinateSystem: 'geo', - data: mapNodes, - showEffectOn: 'render', - rippleEffect: { - scale: 4, - brushType: 'stroke', - }, - symbolSize: (value: unknown) => { - const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1; - return Math.max(10, Math.min(24, 10 + Math.log10(size + 1) * 4.5)); - }, - label: { - show: mapNodes.length <= 8, - position: 'right', - distance: 8, - formatter: '{b}', - color: mapPalette.labelColor, - fontSize: 11, - backgroundColor: isDark ? 'rgba(8,15,31,0.7)' : 'rgba(255,255,255,0.88)', - borderColor: isDark - ? 'rgba(148,163,184,0.16)' - : 'rgba(148,163,184,0.2)', - borderWidth: 1, - borderRadius: 999, - padding: [4, 8], - }, - emphasis: { - scale: true, - label: { - show: true, - }, - }, - }, - ], - }), - [isDark, mapNodes, mapPalette], - ); - return (
@@ -567,8 +280,8 @@ export function WorldStage({ isDark ? 'text-slate-300' : 'text-slate-600', )} > - 先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏, - 让总览页真正承担值守入口的职责。 + 在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源, + 让首屏可以直接承担值守与研判入口。

-
-
+
+
- {geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'} + {geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
- - - + + +
-
- {mapReady ? ( - { - if (params.data?.route) { - router.push(params.data.route); - } - }, - }} - style={{ height: '100%', width: '100%' }} - /> - ) : ( -
- -
- )} +
+
+ {shouldRenderMap ? ( + + ) : ( +
+ +
+ )} +
- {mapReady && nodes.length === 0 ? ( + {shouldRenderMap && nodes.length === 0 ? (
- 当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。 + 当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
) : null} -
+
{sourceCountries.length > 0 ? ( sourceCountries.slice(0, 3).map((item, index) => ( - 当前还没有可用于全球分布展示的来源国家数据。 + 暂无可用于全球来源展示的国家分布数据。
)}
@@ -682,41 +384,41 @@ export function WorldStage({
- 当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。 - 未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。 + 当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。 + 未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。
-
+
@@ -737,7 +439,7 @@ export function WorldStage({ isDark ? 'text-slate-300' : 'text-slate-500', )} > - 节点健康清单 + 节点风险清单

- 风险优先队列 + 优先处置节点

- 请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '} + 请求量 {node.request_count.toLocaleString('zh-CN')} · 错误数{' '} {node.error_count.toLocaleString('zh-CN')}

@@ -816,7 +518,7 @@ export function WorldStage({ : 'border-slate-300/70 bg-slate-50/75 text-slate-600', )} > - 当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。 + 当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
) : null}
diff --git a/atsf_server/web/features/nodes/components/node-detail-page.tsx b/atsf_server/web/features/nodes/components/node-detail-page.tsx index 02df12f2..57c77464 100644 --- a/atsf_server/web/features/nodes/components/node-detail-page.tsx +++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx @@ -2,11 +2,8 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; import { RankChart } from '@/components/data/rank-chart'; import { TrendChart } from '@/components/data/trend-chart'; @@ -31,9 +28,9 @@ import { requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; +import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import type { NodeAgentReleaseInfo, - NodeMutationPayload, NodeObservability, } from '@/features/nodes/types'; import { @@ -43,7 +40,6 @@ import { ResourceField, ResourceInput, SecondaryButton, - ToggleField, } from '@/features/shared/components/resource-primitives'; import type { ReleaseChannel } from '@/features/update/types'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; @@ -62,17 +58,6 @@ import { const nodesQueryKey = ['nodes']; -const nodeSchema = z.object({ - name: z - .string() - .trim() - .min(1, '请输入节点名') - .max(128, '节点名不能超过 128 个字符'), - auto_update_enabled: z.boolean(), -}); - -type NodeFormValues = z.infer; - type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; @@ -80,32 +65,10 @@ type FeedbackState = { type HealthEventFilter = 'all' | 'active' | 'resolved'; -const defaultValues: NodeFormValues = { - name: '', - auto_update_enabled: false, -}; - function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } -function toPayload( - values: NodeFormValues, - currentNode?: { - geo_name?: string; - geo_latitude?: number | null; - geo_longitude?: number | null; - } | null, -): NodeMutationPayload { - return { - name: values.name.trim(), - auto_update_enabled: values.auto_update_enabled, - geo_name: currentNode?.geo_name ?? '', - geo_latitude: currentNode?.geo_latitude ?? null, - geo_longitude: currentNode?.geo_longitude ?? null, - }; -} - async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } @@ -303,16 +266,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const [healthEventFilter, setHealthEventFilter] = useState('all'); - const form = useForm({ - resolver: zodResolver(nodeSchema), - defaultValues, - }); - - const watchedAutoUpdate = useWatch({ - control: form.control, - name: 'auto_update_enabled', - }); - const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, @@ -364,27 +317,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { } }, [serverUrl]); - useEffect(() => { - if (!node || isEditorOpen) { - return; - } - - form.reset({ - name: node.name, - auto_update_enabled: node.auto_update_enabled, - }); - }, [form, isEditorOpen, node]); - const saveMutation = useMutation({ - mutationFn: async (values: NodeFormValues) => - updateNode(Number(nodeId), toPayload(values, node)), + mutationFn: async (payload: Parameters[1]) => + updateNode(Number(nodeId), payload), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: '节点已更新。' }); setIsEditorOpen(false); - form.reset({ - name: updatedNode.name, - auto_update_enabled: updatedNode.auto_update_enabled, - }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { @@ -445,11 +383,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }, }); - const handleSubmit = form.handleSubmit((values) => { - setFeedback(null); - saveMutation.mutate(values); - }); - const handleDelete = () => { if (!node) { return; @@ -1697,59 +1630,19 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { onClose={() => setIsTargetSnapshotOpen(false)} /> - setIsEditorOpen(false)} - title="编辑" + title="编辑节点" description="更新模式和节点名都在详情页维护。" - footer={ -
- setIsEditorOpen(false)} - disabled={saveMutation.isPending} - > - 取消 - - - {saveMutation.isPending ? '保存中...' : '保存修改'} - -
- } - > -
- - - - - - form.setValue('auto_update_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - -
+ submitLabel="保存修改" + onSubmit={(payload) => { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> { + if (!values.geo_manual_override) { + return; + } + + const hasLatitude = values.geo_latitude !== ''; + const hasLongitude = values.geo_longitude !== ''; + + if (hasLatitude !== hasLongitude) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['geo_latitude'], + message: '纬度和经度需要同时填写', + }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['geo_longitude'], + message: '纬度和经度需要同时填写', + }); + return; + } + + if (hasLatitude) { + const latitude = Number(values.geo_latitude); + if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['geo_latitude'], + message: '纬度必须在 -90 到 90 之间', + }); + } + } + + if (hasLongitude) { + const longitude = Number(values.geo_longitude); + if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['geo_longitude'], + message: '经度必须在 -180 到 180 之间', + }); + } + } + }); + +type NodeEditorValues = z.infer; + +const defaultValues: NodeEditorValues = { + name: '', + ip: '', + auto_update_enabled: false, + geo_manual_override: false, + geo_region: '', + geo_name: '', + geo_latitude: '', + geo_longitude: '', +}; + +function collectPoints(value: unknown, points: Array<[number, number]>) { + if (!Array.isArray(value)) { + return; + } + if ( + value.length >= 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + ) { + points.push([value[0], value[1]]); + return; + } + for (const item of value) { + collectPoints(item, points); + } +} + +function getRegionCenter(feature: GeoJsonFeature) { + const points: Array<[number, number]> = []; + collectPoints(feature.geometry?.coordinates, points); + if (points.length === 0) { + return null; + } + + let minLongitude = Number.POSITIVE_INFINITY; + let maxLongitude = Number.NEGATIVE_INFINITY; + let minLatitude = Number.POSITIVE_INFINITY; + let maxLatitude = Number.NEGATIVE_INFINITY; + + for (const [longitude, latitude] of points) { + minLongitude = Math.min(minLongitude, longitude); + maxLongitude = Math.max(maxLongitude, longitude); + minLatitude = Math.min(minLatitude, latitude); + maxLatitude = Math.max(maxLatitude, latitude); + } + + return { + longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)), + latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)), + }; +} + +function buildRegionOptions() { + const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ?? + []) as GeoJsonFeature[]; + const options = new Map(); + + for (const feature of features) { + const label = feature.properties?.name?.trim(); + if (!label || options.has(label)) { + continue; + } + const center = getRegionCenter(feature); + if (!center) { + continue; + } + options.set(label, { + label, + latitude: center.latitude, + longitude: center.longitude, + }); + } + + return Array.from(options.values()).sort((a, b) => + a.label.localeCompare(b.label), + ); +} + +function buildFormValues(node?: Partial | null): NodeEditorValues { + if (!node) { + return defaultValues; + } + + return { + name: node.name ?? '', + ip: node.ip ?? '', + 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 ?? '' : '', + geo_name: node.geo_name ?? '', + geo_latitude: + node.geo_latitude === undefined || node.geo_latitude === null + ? '' + : String(node.geo_latitude), + geo_longitude: + node.geo_longitude === undefined || node.geo_longitude === null + ? '' + : String(node.geo_longitude), + }; +} + +function toPayload(values: NodeEditorValues): NodeMutationPayload { + if (!values.geo_manual_override) { + return { + name: values.name.trim(), + ip: values.ip.trim(), + auto_update_enabled: values.auto_update_enabled, + geo_manual_override: false, + geo_name: '', + geo_latitude: null, + geo_longitude: null, + }; + } + + return { + name: values.name.trim(), + ip: values.ip.trim(), + auto_update_enabled: values.auto_update_enabled, + geo_manual_override: true, + geo_name: values.geo_name.trim(), + geo_latitude: + values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude), + geo_longitude: + values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude), + }; +} + +export function NodeEditorModal({ + isOpen, + node, + isSubmitting, + title, + description, + submitLabel, + onClose, + onSubmit, +}: { + isOpen: boolean; + node?: Partial | null; + isSubmitting: boolean; + title: string; + description: string; + submitLabel: string; + onClose: () => void; + onSubmit: (payload: NodeMutationPayload) => void; +}) { + const form = useForm({ + resolver: zodResolver(nodeEditorSchema), + defaultValues, + }); + + const watchedAutoUpdate = useWatch({ + control: form.control, + name: 'auto_update_enabled', + }); + const watchedGeoManualOverride = useWatch({ + control: form.control, + name: 'geo_manual_override', + }); + const regionOptions = useMemo(() => buildRegionOptions(), []); + + useEffect(() => { + if (!isOpen) { + return; + } + form.reset(buildFormValues(node)); + }, [form, isOpen, node]); + + const handleSubmit = form.handleSubmit((values) => { + onSubmit(toPayload(values)); + }); + + return ( + + + 取消 + + + {isSubmitting ? '保存中...' : submitLabel} + +
+ } + > +
+ + + + + + + + + + form.setValue('auto_update_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + { + form.setValue('geo_manual_override', checked, { + shouldDirty: true, + shouldValidate: true, + }); + if (!checked) { + form.setValue('geo_region', '', { shouldDirty: true }); + form.setValue('geo_name', '', { shouldDirty: true }); + form.setValue('geo_latitude', '', { shouldDirty: true }); + form.setValue('geo_longitude', '', { shouldDirty: true }); + } + }} + /> + + {watchedGeoManualOverride ? ( + + { + const regionName = event.target.value; + form.setValue('geo_region', regionName, { + shouldDirty: true, + }); + const selectedRegion = regionOptions.find( + (item) => item.label === regionName, + ); + if (!selectedRegion) { + return; + } + form.setValue('geo_name', selectedRegion.label, { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue('geo_latitude', String(selectedRegion.latitude), { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue( + 'geo_longitude', + String(selectedRegion.longitude), + { + shouldDirty: true, + shouldValidate: true, + }, + ); + }} + > + + {regionOptions.map((option) => ( + + ))} + + + ) : null} + + + + + +
+ + + + + + + +
+ + + ); +} diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx index 47b975aa..1d4c22e5 100644 --- a/atsf_server/web/features/nodes/components/nodes-page.tsx +++ b/atsf_server/web/features/nodes/components/nodes-page.tsx @@ -2,18 +2,14 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; 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 { PageHeader } from '@/components/layout/page-header'; -import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getConfigVersions } from '@/features/config-versions/api/config-versions'; @@ -23,14 +19,11 @@ import { getNodes, updateNode, } from '@/features/nodes/api/nodes'; -import type { NodeMutationPayload } from '@/features/nodes/types'; +import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; +import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; import { DangerButton, - PrimaryButton, - ResourceField, - ResourceInput, SecondaryButton, - ToggleField, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { @@ -53,105 +46,22 @@ const supportedRiskFilters = [ type NodeRiskFilter = (typeof supportedRiskFilters)[number]; -const nodeSchema = z.object({ - name: z - .string() - .trim() - .min(1, '请输入节点名') - .max(128, '节点名不能超过 128 个字符'), - auto_update_enabled: z.boolean(), - geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'), - geo_latitude: z.string().trim(), - geo_longitude: z.string().trim(), -}).superRefine((values, ctx) => { - const hasLatitude = values.geo_latitude !== ''; - const hasLongitude = values.geo_longitude !== ''; - - if (hasLatitude !== hasLongitude) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['geo_latitude'], - message: '纬度和经度需要同时填写', - }); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['geo_longitude'], - message: '纬度和经度需要同时填写', - }); - return; - } - - if (hasLatitude) { - const latitude = Number(values.geo_latitude); - if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['geo_latitude'], - message: '纬度必须在 -90 到 90 之间', - }); - } - } - - if (hasLongitude) { - const longitude = Number(values.geo_longitude); - if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['geo_longitude'], - message: '经度必须在 -180 到 180 之间', - }); - } - } -}); - -type NodeFormValues = z.infer; - type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; -const defaultValues: NodeFormValues = { - name: '', - auto_update_enabled: false, - geo_name: '', - geo_latitude: '', - geo_longitude: '', -}; - function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } -function toPayload(values: NodeFormValues): NodeMutationPayload { - return { - name: values.name.trim(), - auto_update_enabled: values.auto_update_enabled, - geo_name: values.geo_name.trim(), - geo_latitude: - values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude), - geo_longitude: - values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude), - }; -} - export function NodesPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); - const [editingNodeId, setEditingNodeId] = useState(null); + const [editingNode, setEditingNode] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); - const form = useForm({ - resolver: zodResolver(nodeSchema), - defaultValues, - }); - - const watchedAutoUpdate = useWatch({ - control: form.control, - name: 'auto_update_enabled', - }); - const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, @@ -165,20 +75,18 @@ export function NodesPage() { }); const saveMutation = useMutation({ - mutationFn: async (values: NodeFormValues) => { - const payload = toPayload(values); - return editingNodeId - ? updateNode(editingNodeId, payload) + mutationFn: async (payload: NodeMutationPayload) => { + return editingNode + ? updateNode(editingNode.id, payload) : createNode(payload); }, onSuccess: async () => { setFeedback({ tone: 'success', - message: editingNodeId ? '节点已更新。' : '节点已创建。', + message: editingNode ? '节点已更新。' : '节点已创建。', }); - setEditingNodeId(null); + setEditingNode(null); setIsEditorOpen(false); - form.reset(defaultValues); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { @@ -190,8 +98,7 @@ export function NodesPage() { mutationFn: deleteNode, onSuccess: async () => { setFeedback({ tone: 'success', message: '节点已删除。' }); - setEditingNodeId(null); - form.reset(defaultValues); + setEditingNode(null); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { @@ -247,41 +154,19 @@ export function NodesPage() { const handleReset = () => { setFeedback(null); - setEditingNodeId(null); + setEditingNode(null); setIsEditorOpen(false); - form.reset(defaultValues); }; const handleCreate = () => { setFeedback(null); - setEditingNodeId(null); - form.reset(defaultValues); + setEditingNode(null); setIsEditorOpen(true); }; - const handleEdit = ( - nodeId: number, - name: string, - autoUpdateEnabled: boolean, - geoName: string, - geoLatitude?: number | null, - geoLongitude?: number | null, - ) => { + const handleEdit = (node: NodeItem) => { setFeedback(null); - setEditingNodeId(nodeId); - form.reset({ - name, - auto_update_enabled: autoUpdateEnabled, - geo_name: geoName, - geo_latitude: - geoLatitude === undefined || geoLatitude === null - ? '' - : String(geoLatitude), - geo_longitude: - geoLongitude === undefined || geoLongitude === null - ? '' - : String(geoLongitude), - }); + setEditingNode(node); setIsEditorOpen(true); }; @@ -297,12 +182,6 @@ export function NodesPage() { setFeedback(null); deleteMutation.mutate(nodeId); }; - - const handleSubmit = form.handleSubmit((values) => { - setFeedback(null); - saveMutation.mutate(values); - }); - return ( <>
@@ -487,16 +366,7 @@ export function NodesPage() { - handleEdit( - node.id, - node.name, - node.auto_update_enabled, - node.geo_name, - node.geo_latitude, - node.geo_longitude, - ) - } + onClick={() => handleEdit(node)} className="px-3 py-2 text-xs" > 编辑 @@ -520,98 +390,19 @@ export function NodesPage() { )}
- - - 取消 - - - {saveMutation.isPending - ? '保存中...' - : editingNodeId - ? '保存修改' - : '新增节点'} - -
- } - > -
- - - - - - form.setValue('auto_update_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - - - - - -
- - - - - - - -
- - + submitLabel={editingNode ? '保存修改' : '新增节点'} + onSubmit={(payload) => { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> ); } diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts index 176f255d..e09ae954 100644 --- a/atsf_server/web/features/nodes/types.ts +++ b/atsf_server/web/features/nodes/types.ts @@ -8,6 +8,7 @@ export interface NodeItem { geo_name: string; geo_latitude?: number | null; geo_longitude?: number | null; + geo_manual_override: boolean; agent_token: string; auto_update_enabled: boolean; update_requested: boolean; @@ -39,10 +40,12 @@ export interface NodeBootstrapToken { export interface NodeMutationPayload { name: string; + ip: string; auto_update_enabled: boolean; geo_name: string; geo_latitude?: number | null; geo_longitude?: number | null; + geo_manual_override: boolean; } export interface NodeAgentReleaseInfo { diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index e96be958..456bc3aa 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -72,7 +72,7 @@ const defaultOperationFields = { AgentHeartbeatInterval: '10000', NodeOfflineThreshold: '120000', AgentUpdateRepo: 'Rain-kl/ATSFlare', - GeoIPProvider: 'disabled', + GeoIPProvider: 'ipinfo', OpenRestyWorkerProcesses: 'auto', OpenRestyWorkerConnections: '4096', OpenRestyWorkerRlimitNofile: '65535', @@ -339,7 +339,7 @@ export function SettingsPage() { AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000', NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare', - GeoIPProvider: optionMap.GeoIPProvider ?? 'disabled', + GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo', OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto', OpenRestyWorkerConnections: optionMap.OpenRestyWorkerConnections ?? '4096', @@ -919,171 +919,147 @@ export function SettingsPage() {
- - window.open( - '/swagger/index.html', - '_blank', - 'noopener,noreferrer', - ) - } - > - 打开接口文档 - - - void runBusyAction('operation-intervals', async () => { - const heartbeat = Number.parseInt( - operationFields.AgentHeartbeatInterval, - 10, - ); - const offline = Number.parseInt( - operationFields.NodeOfflineThreshold, - 10, - ); + + void runBusyAction('operation-settings', async () => { + const heartbeat = Number.parseInt( + operationFields.AgentHeartbeatInterval, + 10, + ); + const offline = Number.parseInt( + operationFields.NodeOfflineThreshold, + 10, + ); - if (Number.isNaN(heartbeat) || heartbeat < 5000) { - throw new Error('心跳间隔不能小于 5000 毫秒。'); - } - if (Number.isNaN(offline) || offline < 10000) { - throw new Error('离线阈值不能小于 10000 毫秒。'); - } + if (Number.isNaN(heartbeat) || heartbeat < 5000) { + throw new Error('心跳间隔不能小于 5000 毫秒。'); + } + if (Number.isNaN(offline) || offline < 10000) { + throw new Error('离线阈值不能小于 10000 毫秒。'); + } - await saveOptionEntries( - [ - ['AgentHeartbeatInterval', String(heartbeat)], - ['NodeOfflineThreshold', String(offline)], - ], - 'Agent 运行参数已保存。', - ); - }) - } - disabled={busyKey === 'operation-intervals'} - > - {busyKey === 'operation-intervals' - ? '保存中...' - : '保存运行参数'} - -
+ await saveOptionEntries( + [ + ['AgentHeartbeatInterval', String(heartbeat)], + ['NodeOfflineThreshold', String(offline)], + ['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()], + ['GeoIPProvider', operationFields.GeoIPProvider], + ], + '运维设置已保存。', + ); + }) + } + > + {busyKey === 'operation-settings' ? '保存中...' : '保存设置'} + } > -
- - - setOperationFields((previous) => ({ - ...previous, - AgentHeartbeatInterval: event.target.value, - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - NodeOfflineThreshold: event.target.value, - })) - } - /> - +
+
+
+

+ Agent 运行参数 +

+

+ 修改后会在下个心跳周期同步到节点。 +

+
+
+ + + setOperationFields((previous) => ({ + ...previous, + AgentHeartbeatInterval: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + NodeOfflineThreshold: event.target.value, + })) + } + /> + +
+
+ +
+
+

+ Agent 更新仓库 +

+

+ 自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。 +

+
+
+ + + setOperationFields((previous) => ({ + ...previous, + AgentUpdateRepo: event.target.value, + })) + } + placeholder="Rain-kl/ATSFlare" + /> + +
+
+ +
+
+

+ IP 归属方式 +

+

+ 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。 +

+
+
+ + + setOperationFields((previous) => ({ + ...previous, + GeoIPProvider: event.target.value, + })) + } + > + + + + + + + +
+
- - void runBusyAction('operation-repo', async () => { - await saveOptionEntries( - [ - [ - 'AgentUpdateRepo', - operationFields.AgentUpdateRepo.trim(), - ], - ], - 'Agent 更新仓库已保存。', - ); - }) - } - disabled={busyKey === 'operation-repo'} - > - {busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'} - - } - > - - - setOperationFields((previous) => ({ - ...previous, - AgentUpdateRepo: event.target.value, - })) - } - placeholder="Rain-kl/ATSFlare" - /> - - - - void runBusyAction('operation-geoip', async () => { - await saveOptionEntries( - [['GeoIPProvider', operationFields.GeoIPProvider]], - 'IP 归属方式已保存。', - ); - }) - } - disabled={busyKey === 'operation-geoip'} - > - {busyKey === 'operation-geoip' ? '保存中...' : '保存归属方式'} - - } - > - - - setOperationFields((previous) => ({ - ...previous, - GeoIPProvider: event.target.value, - })) - } - > - - - - - - - - - void runBusyAction('system-general', async () => { - await saveOptionEntries( - [ +
+ + window.open( + '/swagger/index.html', + '_blank', + 'noopener,noreferrer', + ) + } + > + 打开接口文档 + + + void runBusyAction('system-general', async () => { + await saveOptionEntries( [ - 'ServerAddress', - normalizeServerUrl(systemFields.ServerAddress), + [ + 'ServerAddress', + normalizeServerUrl(systemFields.ServerAddress), + ], ], - ], - '通用设置已保存。', - ); - }) - } - disabled={busyKey === 'system-general'} - > - {busyKey === 'system-general' ? '保存中...' : '保存通用设置'} - + '通用设置已保存。', + ); + }) + } + disabled={busyKey === 'system-general'} + > + {busyKey === 'system-general' ? '保存中...' : '保存通用设置'} + +
} > diff --git a/docs/app-config.md b/docs/app-config.md index f41a5681..f99763ee 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -111,7 +111,7 @@ volumes: | `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` | | `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` | | `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/ATSFlare` | -| `GeoIPProvider` | IP 归属解析方式;支持 `disabled`、`mmdb`、`ip-api`、`geojs`、`ipinfo` | `disabled` | +| `GeoIPProvider` | IP 归属解析方式;支持 `disabled`、`mmdb`、`ip-api`、`geojs`、`ipinfo` | `ipinfo` | | `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口(秒) | `300` / `180` | | `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` | | `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` |