mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat: add geo_manual_override to NodeItem and NodeMutationPayload
fix: update default GeoIPProvider to ipinfo in settings feat: implement WorldStageMap component for visualizing node health on a world map feat: create NodeEditorModal for editing node details with manual geo location options docs: update app-config documentation to reflect changes in GeoIPProvider default value
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<typeof echarts.registerMap>[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<MapNodeDatum[]>(
|
||||
() =>
|
||||
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<EChartsCoreOption>(
|
||||
() => ({
|
||||
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 [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
].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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={mapOption}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
opts={{ renderer: 'canvas' }}
|
||||
onEvents={{
|
||||
click: (params: { data?: MapNodeDatum }) => {
|
||||
if (params.data?.route) {
|
||||
router.push(params.data.route);
|
||||
}
|
||||
},
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof echarts.registerMap>[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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[24px] border px-4 py-4 backdrop-blur',
|
||||
'rounded-[24px] border px-5 py-5 backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/10 bg-white/6'
|
||||
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
|
||||
@@ -206,7 +77,7 @@ function HeroMetric({
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 text-2xl font-semibold',
|
||||
'mt-3 text-[30px] font-semibold leading-none',
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
@@ -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<HTMLDivElement | null>(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<MapNodeDatum[]>(
|
||||
() =>
|
||||
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<EChartsOption>(
|
||||
() => ({
|
||||
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 [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求 ${data.requestCount.toLocaleString('zh-CN')} · 错误 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动异常 ${data.activeEventCount} · 节点 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
].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 (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -539,7 +252,7 @@ export function WorldStage({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-6 py-5 md:px-7',
|
||||
'border-b px-6 py-6 md:px-7 md:py-7',
|
||||
isDark ? 'border-white/8' : 'border-slate-200/70',
|
||||
)}
|
||||
>
|
||||
@@ -567,8 +280,8 @@ export function WorldStage({
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
|
||||
让总览页真正承担值守入口的职责。
|
||||
在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源,
|
||||
让首屏可以直接承担值守与研判入口。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -584,11 +297,11 @@ export function WorldStage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-6 py-6 md:px-7 xl:grid-cols-[1.4fr_0.8fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.4fr_0.8fr]">
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[420px] overflow-hidden rounded-[28px] border',
|
||||
'relative min-h-[456px] overflow-hidden rounded-[28px] border py-5',
|
||||
isDark
|
||||
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
|
||||
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
|
||||
@@ -602,45 +315,34 @@ export function WorldStage({
|
||||
: 'bg-sky-100/90 text-sky-700',
|
||||
)}
|
||||
>
|
||||
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
|
||||
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
|
||||
<LegendPill label="绿色: 运行健康" tone="healthy" isDark={isDark} />
|
||||
<LegendPill label="黄色: 容量压力" tone="warning" isDark={isDark} />
|
||||
<LegendPill label="红色: 异常或离线" tone="danger" isDark={isDark} />
|
||||
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
|
||||
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
|
||||
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{mapReady ? (
|
||||
<ReactECharts
|
||||
option={mapOption}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
onEvents={{
|
||||
click: (params: { data?: MapNodeDatum }) => {
|
||||
if (params.data?.route) {
|
||||
router.push(params.data.route);
|
||||
}
|
||||
},
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在准备 ECharts 世界地图与节点信号点位。'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={mapViewportRef}
|
||||
className="absolute inset-x-3 top-14 bottom-24 flex items-center justify-center md:inset-x-4 md:top-16 md:bottom-28"
|
||||
>
|
||||
<div className="aspect-[2/1] h-auto max-h-full w-full">
|
||||
{shouldRenderMap ? (
|
||||
<WorldStageMap isDark={isDark} nodes={nodes} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title="全球地图准备中"
|
||||
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapReady && nodes.length === 0 ? (
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-6 bottom-24 z-10">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -650,12 +352,12 @@ export function WorldStage({
|
||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。
|
||||
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
|
||||
<div className="absolute bottom-5 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
|
||||
{sourceCountries.length > 0 ? (
|
||||
sourceCountries.slice(0, 3).map((item, index) => (
|
||||
<CountrySignal
|
||||
@@ -674,7 +376,7 @@ export function WorldStage({
|
||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前还没有可用于全球分布展示的来源国家数据。
|
||||
暂无可用于全球来源展示的国家分布数据。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -682,41 +384,41 @@ export function WorldStage({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[24px] border px-4 py-3 text-xs leading-6 backdrop-blur',
|
||||
'rounded-[24px] border px-5 py-4 text-xs leading-6 backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/10 bg-white/5 text-slate-300'
|
||||
: 'border-slate-200/80 bg-white/80 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
|
||||
未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
|
||||
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。
|
||||
未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<HeroMetric
|
||||
label="在线覆盖"
|
||||
value={formatPercent(onlineRate)}
|
||||
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
|
||||
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="运行健康"
|
||||
value={formatPercent(healthyRate)}
|
||||
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="配置追平"
|
||||
value={formatPercent(syncRate)}
|
||||
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
|
||||
hint={`${summary.lagging_nodes} 个节点未对齐 ${config.active_version || '当前激活版本'}`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="活动风险"
|
||||
value={summary.active_alerts.toLocaleString('zh-CN')}
|
||||
hint={`${risk.critical_alerts} Critical · ${risk.warning_alerts} Warning`}
|
||||
hint={`${risk.critical_alerts} 个严重告警 · ${risk.warning_alerts} 个预警`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
@@ -737,7 +439,7 @@ export function WorldStage({
|
||||
isDark ? 'text-slate-300' : 'text-slate-500',
|
||||
)}
|
||||
>
|
||||
节点健康清单
|
||||
节点风险清单
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
@@ -745,7 +447,7 @@ export function WorldStage({
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
风险优先队列
|
||||
优先处置节点
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
@@ -788,7 +490,7 @@ export function WorldStage({
|
||||
isDark ? 'text-slate-400' : 'text-slate-500',
|
||||
)}
|
||||
>
|
||||
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
|
||||
请求量 {node.request_count.toLocaleString('zh-CN')} · 错误数{' '}
|
||||
{node.error_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -816,7 +518,7 @@ export function WorldStage({
|
||||
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
|
||||
当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof nodeSchema>;
|
||||
|
||||
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<HealthEventFilter>('all');
|
||||
|
||||
const form = useForm<NodeFormValues>({
|
||||
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<typeof updateNode>[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)}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
<NodeEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
node={node}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
title="编辑"
|
||||
title="编辑节点"
|
||||
description="更新模式和节点名都在详情页维护。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen(false)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="node-detail-editor-form"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? '保存中...' : '保存修改'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="node-detail-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ResourceField
|
||||
label="节点名"
|
||||
hint="示例:shanghai-edge-1"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="shanghai-edge-1"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="启用自动更新"
|
||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</AppModal>
|
||||
submitLabel="保存修改"
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={isAgentUpdateModalOpen}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
||||
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
type GeoJsonGeometry = {
|
||||
type: string;
|
||||
coordinates: unknown;
|
||||
};
|
||||
|
||||
type GeoJsonFeature = {
|
||||
geometry?: GeoJsonGeometry;
|
||||
properties?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RegionOption = {
|
||||
label: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const nodeEditorSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入节点名')
|
||||
.max(128, '节点名不能超过 128 个字符'),
|
||||
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_region: z.string(),
|
||||
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
||||
geo_latitude: z.string().trim(),
|
||||
geo_longitude: z.string().trim(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
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<typeof nodeEditorSchema>;
|
||||
|
||||
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<string, RegionOption>();
|
||||
|
||||
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<NodeItem> | 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<NodeItem> | null;
|
||||
isSubmitting: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => void;
|
||||
}) {
|
||||
const form = useForm<NodeEditorValues>({
|
||||
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 (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="node-editor-form"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '保存中...' : submitLabel}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
||||
<ResourceField
|
||||
label="节点名"
|
||||
hint="示例:shanghai-edge-1"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="shanghai-edge-1"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="节点 IP"
|
||||
hint="可手动维护节点当前对外 IP;留空则等待 Agent 注册或心跳自动回填。"
|
||||
error={form.formState.errors.ip?.message}
|
||||
>
|
||||
<ResourceInput placeholder="203.0.113.10" {...form.register('ip')} />
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="启用自动更新"
|
||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
label="手动指定地图地区"
|
||||
description="关闭时,节点会根据当前 IP 自动解析归属地;开启后使用你手动选择的地区与坐标。"
|
||||
checked={watchedGeoManualOverride}
|
||||
onChange={(checked) => {
|
||||
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 ? (
|
||||
<ResourceField
|
||||
label="地区选择"
|
||||
hint="选择后会自动填充位置名与地图坐标,你也可以继续微调。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={form.watch('geo_region')}
|
||||
onChange={(event) => {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">请选择地区</option>
|
||||
{regionOptions.map((option) => (
|
||||
<option key={option.label} value={option.label}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
|
||||
<ResourceField
|
||||
label="地图位置名"
|
||||
hint={
|
||||
watchedGeoManualOverride
|
||||
? '可在自动填充后继续修改,例如使用更贴近业务的展示名称。'
|
||||
: '自动解析模式下,该字段会由系统根据节点 IP 回填。'
|
||||
}
|
||||
error={form.formState.errors.geo_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Shanghai"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="纬度"
|
||||
hint="范围 -90 到 90,例如上海约为 31.2304"
|
||||
error={form.formState.errors.geo_latitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="31.2304"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_latitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="经度"
|
||||
hint="范围 -180 到 180,例如上海约为 121.4737"
|
||||
error={form.formState.errors.geo_longitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="121.4737"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_longitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof nodeSchema>;
|
||||
|
||||
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<FeedbackState | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const form = useForm<NodeFormValues>({
|
||||
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 (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
@@ -487,16 +366,7 @@ export function NodesPage() {
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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() {
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppModal
|
||||
<NodeEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
node={editingNode}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
title={editingNode ? '编辑节点' : '新增节点'}
|
||||
onClose={handleReset}
|
||||
title={editingNodeId ? '编辑节点' : '新增节点'}
|
||||
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="node-editor-form"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending
|
||||
? '保存中...'
|
||||
: editingNodeId
|
||||
? '保存修改'
|
||||
: '新增节点'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="node-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ResourceField
|
||||
label="节点名"
|
||||
hint="示例:shanghai-edge-1"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="shanghai-edge-1"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="启用自动更新"
|
||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="地图位置名"
|
||||
hint="示例:Shanghai / Tokyo / Frankfurt,可用于总览世界板标注。"
|
||||
error={form.formState.errors.geo_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Shanghai"
|
||||
{...form.register('geo_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="纬度"
|
||||
hint="范围 -90 到 90,例如上海约为 31.2304"
|
||||
error={form.formState.errors.geo_latitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="31.2304"
|
||||
{...form.register('geo_latitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="经度"
|
||||
hint="范围 -180 到 180,例如上海约为 121.4737"
|
||||
error={form.formState.errors.geo_longitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="121.4737"
|
||||
{...form.register('geo_longitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
submitLabel={editingNode ? '保存修改' : '新增节点'}
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||
<AppCard
|
||||
title="接口文档与 Agent 运行参数"
|
||||
description="这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。"
|
||||
title="Agent 接入设置"
|
||||
description="集中维护 Agent 运行参数、更新仓库与 IP 归属方式。运行参数会通过心跳响应下发到 Agent。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'/swagger/index.html',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
>
|
||||
打开接口文档
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('operation-intervals', async () => {
|
||||
const heartbeat = Number.parseInt(
|
||||
operationFields.AgentHeartbeatInterval,
|
||||
10,
|
||||
);
|
||||
const offline = Number.parseInt(
|
||||
operationFields.NodeOfflineThreshold,
|
||||
10,
|
||||
);
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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'
|
||||
? '保存中...'
|
||||
: '保存运行参数'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
await saveOptionEntries(
|
||||
[
|
||||
['AgentHeartbeatInterval', String(heartbeat)],
|
||||
['NodeOfflineThreshold', String(offline)],
|
||||
['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()],
|
||||
['GeoIPProvider', operationFields.GeoIPProvider],
|
||||
],
|
||||
'运维设置已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
{busyKey === 'operation-settings' ? '保存中...' : '保存设置'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.AgentHeartbeatInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
AgentHeartbeatInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.NodeOfflineThreshold}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
NodeOfflineThreshold: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
Agent 运行参数
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-muted)]">
|
||||
修改后会在下个心跳周期同步到节点。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.AgentHeartbeatInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
AgentHeartbeatInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.NodeOfflineThreshold}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
NodeOfflineThreshold: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-default)] pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
Agent 更新仓库
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-muted)]">
|
||||
自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ResourceField label="GitHub 仓库">
|
||||
<ResourceInput
|
||||
value={operationFields.AgentUpdateRepo}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
AgentUpdateRepo: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Rain-kl/ATSFlare"
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-default)] pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
IP 归属方式
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-muted)]">
|
||||
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ResourceField
|
||||
label="归属方式"
|
||||
hint="disabled 关闭解析;mmdb 使用本地数据库;其余选项调用外部 GeoIP 服务。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={operationFields.GeoIPProvider}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
GeoIPProvider: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="disabled">关闭</option>
|
||||
<option value="mmdb">MaxMind mmdb</option>
|
||||
<option value="ip-api">ip-api.com</option>
|
||||
<option value="geojs">geojs.io</option>
|
||||
<option value="ipinfo">ipinfo.io</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Agent 更新仓库"
|
||||
description="自动更新和手动更新动作在节点页触发,这里只维护 Agent 自更新时使用的仓库地址。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('operation-repo', async () => {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
[
|
||||
'AgentUpdateRepo',
|
||||
operationFields.AgentUpdateRepo.trim(),
|
||||
],
|
||||
],
|
||||
'Agent 更新仓库已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'operation-repo'}
|
||||
>
|
||||
{busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<ResourceField label="GitHub 仓库">
|
||||
<ResourceInput
|
||||
value={operationFields.AgentUpdateRepo}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
AgentUpdateRepo: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Rain-kl/ATSFlare"
|
||||
/>
|
||||
</ResourceField>
|
||||
</AppCard>
|
||||
<AppCard
|
||||
title="IP 归属方式"
|
||||
description="控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('operation-geoip', async () => {
|
||||
await saveOptionEntries(
|
||||
[['GeoIPProvider', operationFields.GeoIPProvider]],
|
||||
'IP 归属方式已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'operation-geoip'}
|
||||
>
|
||||
{busyKey === 'operation-geoip' ? '保存中...' : '保存归属方式'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<ResourceField
|
||||
label="归属方式"
|
||||
hint="disabled 关闭解析;mmdb 使用本地数据库;其余选项调用外部 GeoIP 服务。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={operationFields.GeoIPProvider}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
GeoIPProvider: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="disabled">关闭</option>
|
||||
<option value="mmdb">MaxMind mmdb</option>
|
||||
<option value="ip-api">ip-api.com</option>
|
||||
<option value="geojs">geojs.io</option>
|
||||
<option value="ipinfo">ipinfo.io</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</AppCard>
|
||||
<AppCard
|
||||
title="Discovery Token 与部署命令"
|
||||
description="适用于新节点首次接入。可直接复制一键安装命令。"
|
||||
@@ -1275,25 +1251,39 @@ export function SettingsPage() {
|
||||
title="通用设置"
|
||||
description="服务器地址会影响邮件链接、OAuth 回调和部署命令展示。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('system-general', async () => {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'/swagger/index.html',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
>
|
||||
打开接口文档
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('system-general', async () => {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
'ServerAddress',
|
||||
normalizeServerUrl(systemFields.ServerAddress),
|
||||
[
|
||||
'ServerAddress',
|
||||
normalizeServerUrl(systemFields.ServerAddress),
|
||||
],
|
||||
],
|
||||
],
|
||||
'通用设置已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'system-general'}
|
||||
>
|
||||
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
|
||||
</PrimaryButton>
|
||||
'通用设置已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'system-general'}
|
||||
>
|
||||
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResourceField label="服务器地址">
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user