mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +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)
|
// V3 operational settings (hot-reloadable via Option table)
|
||||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||||
var AgentUpdateRepo = "Rain-kl/ATSFlare"
|
var AgentUpdateRepo = "Rain-kl/ATSFlare"
|
||||||
var GeoIPProvider = "disabled"
|
var GeoIPProvider = "ipinfo"
|
||||||
|
|
||||||
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
||||||
var OpenRestyWorkerProcesses = "auto"
|
var OpenRestyWorkerProcesses = "auto"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Node struct {
|
|||||||
GeoName string `json:"geo_name" gorm:"size:128"`
|
GeoName string `json:"geo_name" gorm:"size:128"`
|
||||||
GeoLatitude *float64 `json:"geo_latitude"`
|
GeoLatitude *float64 `json:"geo_latitude"`
|
||||||
GeoLongitude *float64 `json:"geo_longitude"`
|
GeoLongitude *float64 `json:"geo_longitude"`
|
||||||
|
GeoManualOverride bool `json:"geo_manual_override" gorm:"not null;default:false"`
|
||||||
AgentToken string `json:"-" gorm:"size:128;index"`
|
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
||||||
UpdateRequested bool `json:"update_requested" 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{
|
createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
|
||||||
"name": "shanghai-edge-1",
|
"name": "shanghai-edge-1",
|
||||||
"geo_name": "Shanghai",
|
"geo_manual_override": true,
|
||||||
"geo_latitude": 31.2304,
|
"geo_name": "Shanghai",
|
||||||
"geo_longitude": 121.4737,
|
"geo_latitude": 31.2304,
|
||||||
|
"geo_longitude": 121.4737,
|
||||||
})
|
})
|
||||||
var createdNode service.NodeView
|
var createdNode service.NodeView
|
||||||
decodeResponseData(t, createdNodeResp, &createdNode)
|
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{
|
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
|
||||||
"name": "shanghai-edge-1-renamed",
|
"name": "shanghai-edge-1-renamed",
|
||||||
"geo_name": "Tokyo",
|
"geo_manual_override": true,
|
||||||
"geo_latitude": 35.6762,
|
"geo_name": "Tokyo",
|
||||||
"geo_longitude": 139.6503,
|
"geo_latitude": 35.6762,
|
||||||
|
"geo_longitude": 139.6503,
|
||||||
})
|
})
|
||||||
decodeResponseData(t, updatedNodeResp, &createdNode)
|
decodeResponseData(t, updatedNodeResp, &createdNode)
|
||||||
if createdNode.Name != "shanghai-edge-1-renamed" {
|
if createdNode.Name != "shanghai-edge-1-renamed" {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type NodeView struct {
|
|||||||
GeoName string `json:"geo_name"`
|
GeoName string `json:"geo_name"`
|
||||||
GeoLatitude *float64 `json:"geo_latitude"`
|
GeoLatitude *float64 `json:"geo_latitude"`
|
||||||
GeoLongitude *float64 `json:"geo_longitude"`
|
GeoLongitude *float64 `json:"geo_longitude"`
|
||||||
|
GeoManualOverride bool `json:"geo_manual_override"`
|
||||||
AgentToken string `json:"agent_token"`
|
AgentToken string `json:"agent_token"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
UpdateRequested bool `json:"update_requested"`
|
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("name", previous.Name, current.Name)
|
||||||
appendIfChanged("ip", previous.IP, current.IP)
|
appendIfChanged("ip", previous.IP, current.IP)
|
||||||
|
appendIfChanged("geo_name", previous.GeoName, current.GeoName)
|
||||||
appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion)
|
appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion)
|
||||||
appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion)
|
appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion)
|
||||||
appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus)
|
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_channel", previous.UpdateChannel, current.UpdateChannel)
|
||||||
appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag)
|
appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag)
|
||||||
appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested)
|
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) {
|
if !previous.LastSeenAt.Equal(current.LastSeenAt) {
|
||||||
changes["last_seen_at"] = current.LastSeenAt
|
changes["last_seen_at"] = current.LastSeenAt
|
||||||
}
|
}
|
||||||
return changes
|
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 (
|
import (
|
||||||
"atsflare/common"
|
"atsflare/common"
|
||||||
"atsflare/model"
|
"atsflare/model"
|
||||||
|
"atsflare/utils/geoip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeInput struct {
|
type NodeInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
GeoName string `json:"geo_name"`
|
GeoName string `json:"geo_name"`
|
||||||
GeoLatitude *float64 `json:"geo_latitude"`
|
GeoLatitude *float64 `json:"geo_latitude"`
|
||||||
GeoLongitude *float64 `json:"geo_longitude"`
|
GeoLongitude *float64 `json:"geo_longitude"`
|
||||||
|
GeoManualOverride bool `json:"geo_manual_override"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeAgentUpdateInput struct {
|
type NodeAgentUpdateInput struct {
|
||||||
@@ -50,16 +54,17 @@ type AgentRegistrationResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreateNode(input NodeInput) (*NodeView, error) {
|
func CreateNode(input NodeInput) (*NodeView, error) {
|
||||||
name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input)
|
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, errors.New("节点名不能为空")
|
return nil, errors.New("节点名不能为空")
|
||||||
}
|
}
|
||||||
node := &model.Node{
|
node := &model.Node{
|
||||||
Name: name,
|
Name: name,
|
||||||
IP: "",
|
IP: ip,
|
||||||
GeoName: geoName,
|
GeoName: geoName,
|
||||||
GeoLatitude: geoLatitude,
|
GeoLatitude: geoLatitude,
|
||||||
GeoLongitude: geoLongitude,
|
GeoLongitude: geoLongitude,
|
||||||
|
GeoManualOverride: geoManualOverride,
|
||||||
AgentVersion: "",
|
AgentVersion: "",
|
||||||
NginxVersion: "",
|
NginxVersion: "",
|
||||||
Status: NodeStatusPending,
|
Status: NodeStatusPending,
|
||||||
@@ -73,6 +78,9 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !node.GeoManualOverride {
|
||||||
|
applyGeoInfoFromIP(node, node.IP)
|
||||||
|
}
|
||||||
if err := node.Insert(); err != nil {
|
if err := node.Insert(); err != nil {
|
||||||
if isUniqueConstraintError(err) {
|
if isUniqueConstraintError(err) {
|
||||||
return nil, errors.New("节点标识生成冲突,请重试")
|
return nil, errors.New("节点标识生成冲突,请重试")
|
||||||
@@ -84,7 +92,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateNode(id uint, 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 == "" {
|
if name == "" {
|
||||||
return nil, errors.New("节点名不能为空")
|
return nil, errors.New("节点名不能为空")
|
||||||
}
|
}
|
||||||
@@ -93,10 +101,15 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node.Name = name
|
node.Name = name
|
||||||
|
node.IP = ip
|
||||||
node.GeoName = geoName
|
node.GeoName = geoName
|
||||||
node.GeoLatitude = geoLatitude
|
node.GeoLatitude = geoLatitude
|
||||||
node.GeoLongitude = geoLongitude
|
node.GeoLongitude = geoLongitude
|
||||||
|
node.GeoManualOverride = geoManualOverride
|
||||||
node.AutoUpdateEnabled = input.AutoUpdateEnabled
|
node.AutoUpdateEnabled = input.AutoUpdateEnabled
|
||||||
|
if !node.GeoManualOverride {
|
||||||
|
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
|
||||||
|
}
|
||||||
if err = node.Update(); err != nil {
|
if err = node.Update(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -242,6 +255,7 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
GeoName: strings.TrimSpace(node.GeoName),
|
GeoName: strings.TrimSpace(node.GeoName),
|
||||||
GeoLatitude: node.GeoLatitude,
|
GeoLatitude: node.GeoLatitude,
|
||||||
GeoLongitude: node.GeoLongitude,
|
GeoLongitude: node.GeoLongitude,
|
||||||
|
GeoManualOverride: node.GeoManualOverride,
|
||||||
AgentToken: node.AgentToken,
|
AgentToken: node.AgentToken,
|
||||||
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
|
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
|
||||||
UpdateTag: strings.TrimSpace(node.UpdateTag),
|
UpdateTag: strings.TrimSpace(node.UpdateTag),
|
||||||
@@ -265,26 +279,41 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
return view
|
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)
|
name := strings.TrimSpace(input.Name)
|
||||||
|
ip := strings.TrimSpace(input.IP)
|
||||||
geoName := strings.TrimSpace(input.GeoName)
|
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 {
|
if len(geoName) > 128 {
|
||||||
return "", "", nil, nil, errors.New("节点位置名不能超过 128 个字符")
|
return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符")
|
||||||
}
|
}
|
||||||
|
|
||||||
geoLatitude := cloneCoordinate(input.GeoLatitude)
|
geoLatitude := cloneCoordinate(input.GeoLatitude)
|
||||||
geoLongitude := cloneCoordinate(input.GeoLongitude)
|
geoLongitude := cloneCoordinate(input.GeoLongitude)
|
||||||
if (geoLatitude == nil) != (geoLongitude == nil) {
|
if (geoLatitude == nil) != (geoLongitude == nil) {
|
||||||
return "", "", nil, nil, errors.New("地图坐标必须同时填写纬度和经度")
|
return "", "", "", nil, nil, false, errors.New("地图坐标必须同时填写纬度和经度")
|
||||||
}
|
}
|
||||||
if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) {
|
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) {
|
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 {
|
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.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||||
node.LastSeenAt = time.Now()
|
node.LastSeenAt = time.Now()
|
||||||
node.LastError = strings.TrimSpace(payload.LastError)
|
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 {
|
func normalizeOpenrestyStatus(status string) string {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"atsflare/common"
|
"atsflare/common"
|
||||||
"atsflare/model"
|
"atsflare/model"
|
||||||
|
"atsflare/utils/geoip"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,20 +15,54 @@ import (
|
|||||||
|
|
||||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
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) {
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
return f(req)
|
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) {
|
func TestRequestNodeAgentPreviewUpdate(t *testing.T) {
|
||||||
setupServiceTestDB(t)
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
latitude := 31.2304
|
latitude := 31.2304
|
||||||
longitude := 121.4737
|
longitude := 121.4737
|
||||||
node, err := CreateNode(NodeInput{
|
node, err := CreateNode(NodeInput{
|
||||||
Name: "preview-edge-1",
|
Name: "preview-edge-1",
|
||||||
GeoName: "Shanghai",
|
GeoManualOverride: true,
|
||||||
GeoLatitude: &latitude,
|
GeoName: "Shanghai",
|
||||||
GeoLongitude: &longitude,
|
GeoLatitude: &latitude,
|
||||||
|
GeoLongitude: &longitude,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create node: %v", err)
|
t.Fatalf("failed to create node: %v", err)
|
||||||
@@ -173,6 +209,7 @@ func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) {
|
|||||||
updated, err := UpdateNode(node.ID, NodeInput{
|
updated, err := UpdateNode(node.ID, NodeInput{
|
||||||
Name: "geo-edge-updated",
|
Name: "geo-edge-updated",
|
||||||
AutoUpdateEnabled: true,
|
AutoUpdateEnabled: true,
|
||||||
|
GeoManualOverride: true,
|
||||||
GeoName: "San Francisco",
|
GeoName: "San Francisco",
|
||||||
GeoLatitude: &latitude,
|
GeoLatitude: &latitude,
|
||||||
GeoLongitude: &longitude,
|
GeoLongitude: &longitude,
|
||||||
@@ -203,13 +240,148 @@ func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) {
|
|||||||
|
|
||||||
latitude := 37.7749
|
latitude := 37.7749
|
||||||
if _, err = UpdateNode(node.ID, NodeInput{
|
if _, err = UpdateNode(node.ID, NodeInput{
|
||||||
Name: "geo-edge-invalid",
|
Name: "geo-edge-invalid",
|
||||||
GeoLatitude: &latitude,
|
GeoManualOverride: true,
|
||||||
|
GeoLatitude: &latitude,
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
t.Fatal("expected partial geo metadata to be rejected")
|
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) {
|
func TestRequestNodeOpenrestyRestart(t *testing.T) {
|
||||||
setupServiceTestDB(t)
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeoInfo struct {
|
type GeoInfo struct {
|
||||||
ISOCode string
|
ISOCode string
|
||||||
Name string
|
Name string
|
||||||
|
Latitude *float64
|
||||||
|
Longitude *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -202,3 +204,7 @@ func getProvider() GeoIPService {
|
|||||||
}
|
}
|
||||||
return CurrentProvider
|
return CurrentProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func float64Pointer(value float64) *float64 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ type GeoJSService struct {
|
|||||||
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
|
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
|
||||||
// 我们只定义我们需要的字段。
|
// 我们只定义我们需要的字段。
|
||||||
type geoJSResponse struct {
|
type geoJSResponse struct {
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
CountryCode string `json:"country_code"`
|
CountryCode string `json:"country_code"`
|
||||||
|
Latitude float64 `json:"latitude,string"`
|
||||||
|
Longitude float64 `json:"longitude,string"`
|
||||||
// 可以根据需要添加其他字段,例如:
|
// 可以根据需要添加其他字段,例如:
|
||||||
// City string `json:"city"`
|
// City string `json:"city"`
|
||||||
// Region string `json:"region"`
|
// Region string `json:"region"`
|
||||||
@@ -64,8 +66,10 @@ func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &GeoInfo{
|
return &GeoInfo{
|
||||||
ISOCode: apiResp.CountryCode,
|
ISOCode: apiResp.CountryCode,
|
||||||
Name: apiResp.Country,
|
Name: apiResp.Country,
|
||||||
|
Latitude: float64Pointer(apiResp.Latitude),
|
||||||
|
Longitude: float64Pointer(apiResp.Longitude),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &GeoInfo{
|
return &GeoInfo{
|
||||||
ISOCode: apiResp.CountryCode,
|
ISOCode: apiResp.CountryCode,
|
||||||
Name: apiResp.Country,
|
Name: apiResp.Country,
|
||||||
|
Latitude: float64Pointer(apiResp.Lat),
|
||||||
|
Longitude: float64Pointer(apiResp.Lon),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"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)
|
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"
|
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
|
||||||
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
|
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
|
||||||
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
|
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
|
||||||
@@ -73,8 +77,10 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
|||||||
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
|
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
|
||||||
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
|
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
|
||||||
return &GeoInfo{
|
return &GeoInfo{
|
||||||
ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code
|
ISOCode: apiResp.Country,
|
||||||
Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称
|
Name: apiResp.Country,
|
||||||
|
Latitude: latitude,
|
||||||
|
Longitude: longitude,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,3 +95,18 @@ func (s *IPInfoService) Close() error {
|
|||||||
// 无需执行任何操作
|
// 无需执行任何操作
|
||||||
return nil
|
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';
|
'use client';
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
import type { EChartsOption } from 'echarts';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { useTheme } from '@/components/providers/theme-provider';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import type {
|
import type {
|
||||||
DashboardConfig,
|
DashboardConfig,
|
||||||
@@ -27,100 +23,15 @@ import {
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
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 Tone = 'healthy' | 'warning' | 'danger';
|
||||||
|
|
||||||
type MapNodeDatum = {
|
const WorldStageMap = dynamic(
|
||||||
id: number;
|
() =>
|
||||||
name: string;
|
import('@/features/dashboard/components/world-stage-map').then(
|
||||||
geoName: string;
|
(module) => module.WorldStageMap,
|
||||||
route: string;
|
),
|
||||||
derivedFromGeo: boolean;
|
{ ssr: false },
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value: number) {
|
function formatPercent(value: number) {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
@@ -136,46 +47,6 @@ function buildNodeDetailHref(id?: number | null) {
|
|||||||
return `/node/detail?id=${id}`;
|
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({
|
function HeroMetric({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -190,7 +61,7 @@ function HeroMetric({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[24px] border px-4 py-4 backdrop-blur',
|
'rounded-[24px] border px-5 py-5 backdrop-blur',
|
||||||
isDark
|
isDark
|
||||||
? 'border-white/10 bg-white/6'
|
? 'border-white/10 bg-white/6'
|
||||||
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
|
: '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>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-3 text-2xl font-semibold',
|
'mt-3 text-[30px] font-semibold leading-none',
|
||||||
isDark ? 'text-white' : 'text-slate-950',
|
isDark ? 'text-white' : 'text-slate-950',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -315,17 +186,38 @@ export function WorldStage({
|
|||||||
nodes: DashboardNodeHealth[];
|
nodes: DashboardNodeHealth[];
|
||||||
sourceCountries: DistributionItem[];
|
sourceCountries: DistributionItem[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isDark = resolvedTheme === 'dark';
|
const isDark = resolvedTheme === 'dark';
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [mapFailed, setMapFailed] = useState(false);
|
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ready = ensureWorldMapRegistered();
|
if (shouldRenderMap) {
|
||||||
setMapReady(ready);
|
return;
|
||||||
setMapFailed(!ready);
|
}
|
||||||
}, []);
|
|
||||||
|
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 =
|
const onlineRate =
|
||||||
summary.total_nodes > 0
|
summary.total_nodes > 0
|
||||||
@@ -349,185 +241,6 @@ export function WorldStage({
|
|||||||
typeof node.geo_longitude === 'number',
|
typeof node.geo_longitude === 'number',
|
||||||
).length;
|
).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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -539,7 +252,7 @@ export function WorldStage({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
isDark ? 'border-white/8' : 'border-slate-200/70',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -567,8 +280,8 @@ export function WorldStage({
|
|||||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
|
在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源,
|
||||||
让总览页真正承担值守入口的职责。
|
让首屏可以直接承担值守与研判入口。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -584,11 +297,11 @@ export function WorldStage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 px-6 py-6 md:px-7 xl:grid-cols-[1.4fr_0.8fr]">
|
<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-4">
|
<div className="space-y-5">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative min-h-[420px] overflow-hidden rounded-[28px] border',
|
'relative min-h-[456px] overflow-hidden rounded-[28px] border py-5',
|
||||||
isDark
|
isDark
|
||||||
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
|
? '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))]',
|
: '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',
|
: 'bg-sky-100/90 text-sky-700',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
|
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
|
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
|
||||||
<LegendPill label="绿色: 运行健康" tone="healthy" isDark={isDark} />
|
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
|
||||||
<LegendPill label="黄色: 容量压力" tone="warning" isDark={isDark} />
|
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
|
||||||
<LegendPill label="红色: 异常或离线" tone="danger" isDark={isDark} />
|
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute inset-0">
|
<div
|
||||||
{mapReady ? (
|
ref={mapViewportRef}
|
||||||
<ReactECharts
|
className="absolute inset-x-3 top-14 bottom-24 flex items-center justify-center md:inset-x-4 md:top-16 md:bottom-28"
|
||||||
option={mapOption}
|
>
|
||||||
notMerge
|
<div className="aspect-[2/1] h-auto max-h-full w-full">
|
||||||
lazyUpdate
|
{shouldRenderMap ? (
|
||||||
onEvents={{
|
<WorldStageMap isDark={isDark} nodes={nodes} />
|
||||||
click: (params: { data?: MapNodeDatum }) => {
|
) : (
|
||||||
if (params.data?.route) {
|
<div className="flex h-full items-center justify-center">
|
||||||
router.push(params.data.route);
|
<EmptyState
|
||||||
}
|
title="全球地图准备中"
|
||||||
},
|
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
||||||
}}
|
/>
|
||||||
style={{ height: '100%', width: '100%' }}
|
</div>
|
||||||
/>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<EmptyState
|
|
||||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
|
||||||
description={
|
|
||||||
mapFailed
|
|
||||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
|
||||||
: '正在准备 ECharts 世界地图与节点信号点位。'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</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="pointer-events-none absolute inset-x-6 bottom-24 z-10">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -650,12 +352,12 @@ export function WorldStage({
|
|||||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。
|
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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.length > 0 ? (
|
||||||
sourceCountries.slice(0, 3).map((item, index) => (
|
sourceCountries.slice(0, 3).map((item, index) => (
|
||||||
<CountrySignal
|
<CountrySignal
|
||||||
@@ -674,7 +376,7 @@ export function WorldStage({
|
|||||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
当前还没有可用于全球分布展示的来源国家数据。
|
暂无可用于全球来源展示的国家分布数据。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -682,41 +384,41 @@ export function WorldStage({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isDark
|
||||||
? 'border-white/10 bg-white/5 text-slate-300'
|
? 'border-white/10 bg-white/5 text-slate-300'
|
||||||
: 'border-slate-200/80 bg-white/80 text-slate-600',
|
: 'border-slate-200/80 bg-white/80 text-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
|
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。
|
||||||
未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
|
未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
<HeroMetric
|
<HeroMetric
|
||||||
label="在线覆盖"
|
label="在线覆盖"
|
||||||
value={formatPercent(onlineRate)}
|
value={formatPercent(onlineRate)}
|
||||||
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
|
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
<HeroMetric
|
<HeroMetric
|
||||||
label="运行健康"
|
label="运行健康"
|
||||||
value={formatPercent(healthyRate)}
|
value={formatPercent(healthyRate)}
|
||||||
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
<HeroMetric
|
<HeroMetric
|
||||||
label="配置追平"
|
label="配置追平"
|
||||||
value={formatPercent(syncRate)}
|
value={formatPercent(syncRate)}
|
||||||
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
|
hint={`${summary.lagging_nodes} 个节点未对齐 ${config.active_version || '当前激活版本'}`}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
<HeroMetric
|
<HeroMetric
|
||||||
label="活动风险"
|
label="活动风险"
|
||||||
value={summary.active_alerts.toLocaleString('zh-CN')}
|
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}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -737,7 +439,7 @@ export function WorldStage({
|
|||||||
isDark ? 'text-slate-300' : 'text-slate-500',
|
isDark ? 'text-slate-300' : 'text-slate-500',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
节点健康清单
|
节点风险清单
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -745,7 +447,7 @@ export function WorldStage({
|
|||||||
isDark ? 'text-white' : 'text-slate-950',
|
isDark ? 'text-white' : 'text-slate-950',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
风险优先队列
|
优先处置节点
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -788,7 +490,7 @@ export function WorldStage({
|
|||||||
isDark ? 'text-slate-400' : 'text-slate-500',
|
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')}
|
{node.error_count.toLocaleString('zh-CN')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -816,7 +518,7 @@ export function WorldStage({
|
|||||||
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
|
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
|
当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { RankChart } from '@/components/data/rank-chart';
|
||||||
import { TrendChart } from '@/components/data/trend-chart';
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
@@ -31,9 +28,9 @@ import {
|
|||||||
requestNodeAgentUpdate,
|
requestNodeAgentUpdate,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from '@/features/nodes/api/nodes';
|
} from '@/features/nodes/api/nodes';
|
||||||
|
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||||
import type {
|
import type {
|
||||||
NodeAgentReleaseInfo,
|
NodeAgentReleaseInfo,
|
||||||
NodeMutationPayload,
|
|
||||||
NodeObservability,
|
NodeObservability,
|
||||||
} from '@/features/nodes/types';
|
} from '@/features/nodes/types';
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +40,6 @@ import {
|
|||||||
ResourceField,
|
ResourceField,
|
||||||
ResourceInput,
|
ResourceInput,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
ToggleField,
|
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import type { ReleaseChannel } from '@/features/update/types';
|
import type { ReleaseChannel } from '@/features/update/types';
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
@@ -62,17 +58,6 @@ import {
|
|||||||
|
|
||||||
const nodesQueryKey = ['nodes'];
|
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 = {
|
type FeedbackState = {
|
||||||
tone: 'info' | 'success' | 'danger';
|
tone: 'info' | 'success' | 'danger';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -80,32 +65,10 @@ type FeedbackState = {
|
|||||||
|
|
||||||
type HealthEventFilter = 'all' | 'active' | 'resolved';
|
type HealthEventFilter = 'all' | 'active' | 'resolved';
|
||||||
|
|
||||||
const defaultValues: NodeFormValues = {
|
|
||||||
name: '',
|
|
||||||
auto_update_enabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
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) {
|
async function copyToClipboard(value: string) {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
}
|
}
|
||||||
@@ -303,16 +266,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
const [healthEventFilter, setHealthEventFilter] =
|
const [healthEventFilter, setHealthEventFilter] =
|
||||||
useState<HealthEventFilter>('all');
|
useState<HealthEventFilter>('all');
|
||||||
|
|
||||||
const form = useForm<NodeFormValues>({
|
|
||||||
resolver: zodResolver(nodeSchema),
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const watchedAutoUpdate = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: 'auto_update_enabled',
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodesQuery = useQuery({
|
const nodesQuery = useQuery({
|
||||||
queryKey: nodesQueryKey,
|
queryKey: nodesQueryKey,
|
||||||
queryFn: getNodes,
|
queryFn: getNodes,
|
||||||
@@ -364,27 +317,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
}
|
}
|
||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!node || isEditorOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: node.name,
|
|
||||||
auto_update_enabled: node.auto_update_enabled,
|
|
||||||
});
|
|
||||||
}, [form, isEditorOpen, node]);
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: NodeFormValues) =>
|
mutationFn: async (payload: Parameters<typeof updateNode>[1]) =>
|
||||||
updateNode(Number(nodeId), toPayload(values, node)),
|
updateNode(Number(nodeId), payload),
|
||||||
onSuccess: async (updatedNode) => {
|
onSuccess: async (updatedNode) => {
|
||||||
setFeedback({ tone: 'success', message: '节点已更新。' });
|
setFeedback({ tone: 'success', message: '节点已更新。' });
|
||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
form.reset({
|
|
||||||
name: updatedNode.name,
|
|
||||||
auto_update_enabled: updatedNode.auto_update_enabled,
|
|
||||||
});
|
|
||||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -445,11 +383,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
setFeedback(null);
|
|
||||||
saveMutation.mutate(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
@@ -1697,59 +1630,19 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
onClose={() => setIsTargetSnapshotOpen(false)}
|
onClose={() => setIsTargetSnapshotOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppModal
|
<NodeEditorModal
|
||||||
isOpen={isEditorOpen}
|
isOpen={isEditorOpen}
|
||||||
|
node={node}
|
||||||
|
isSubmitting={saveMutation.isPending}
|
||||||
onClose={() => setIsEditorOpen(false)}
|
onClose={() => setIsEditorOpen(false)}
|
||||||
title="编辑"
|
title="编辑节点"
|
||||||
description="更新模式和节点名都在详情页维护。"
|
description="更新模式和节点名都在详情页维护。"
|
||||||
footer={
|
submitLabel="保存修改"
|
||||||
<div className="flex flex-wrap justify-end gap-3">
|
onSubmit={(payload) => {
|
||||||
<SecondaryButton
|
setFeedback(null);
|
||||||
type="button"
|
saveMutation.mutate(payload);
|
||||||
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>
|
|
||||||
|
|
||||||
<AppModal
|
<AppModal
|
||||||
isOpen={isAgentUpdateModalOpen}
|
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 Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
||||||
@@ -23,14 +19,11 @@ import {
|
|||||||
getNodes,
|
getNodes,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from '@/features/nodes/api/nodes';
|
} 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 {
|
import {
|
||||||
DangerButton,
|
DangerButton,
|
||||||
PrimaryButton,
|
|
||||||
ResourceField,
|
|
||||||
ResourceInput,
|
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
ToggleField,
|
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
import {
|
import {
|
||||||
@@ -53,105 +46,22 @@ const supportedRiskFilters = [
|
|||||||
|
|
||||||
type NodeRiskFilter = (typeof supportedRiskFilters)[number];
|
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 = {
|
type FeedbackState = {
|
||||||
tone: 'info' | 'success' | 'danger';
|
tone: 'info' | 'success' | 'danger';
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues: NodeFormValues = {
|
|
||||||
name: '',
|
|
||||||
auto_update_enabled: false,
|
|
||||||
geo_name: '',
|
|
||||||
geo_latitude: '',
|
|
||||||
geo_longitude: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
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() {
|
export function NodesPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
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 [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({
|
const nodesQuery = useQuery({
|
||||||
queryKey: nodesQueryKey,
|
queryKey: nodesQueryKey,
|
||||||
queryFn: getNodes,
|
queryFn: getNodes,
|
||||||
@@ -165,20 +75,18 @@ export function NodesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: NodeFormValues) => {
|
mutationFn: async (payload: NodeMutationPayload) => {
|
||||||
const payload = toPayload(values);
|
return editingNode
|
||||||
return editingNodeId
|
? updateNode(editingNode.id, payload)
|
||||||
? updateNode(editingNodeId, payload)
|
|
||||||
: createNode(payload);
|
: createNode(payload);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
message: editingNodeId ? '节点已更新。' : '节点已创建。',
|
message: editingNode ? '节点已更新。' : '节点已创建。',
|
||||||
});
|
});
|
||||||
setEditingNodeId(null);
|
setEditingNode(null);
|
||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
form.reset(defaultValues);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -190,8 +98,7 @@ export function NodesPage() {
|
|||||||
mutationFn: deleteNode,
|
mutationFn: deleteNode,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setFeedback({ tone: 'success', message: '节点已删除。' });
|
setFeedback({ tone: 'success', message: '节点已删除。' });
|
||||||
setEditingNodeId(null);
|
setEditingNode(null);
|
||||||
form.reset(defaultValues);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -247,41 +154,19 @@ export function NodesPage() {
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setEditingNodeId(null);
|
setEditingNode(null);
|
||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
form.reset(defaultValues);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setEditingNodeId(null);
|
setEditingNode(null);
|
||||||
form.reset(defaultValues);
|
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (
|
const handleEdit = (node: NodeItem) => {
|
||||||
nodeId: number,
|
|
||||||
name: string,
|
|
||||||
autoUpdateEnabled: boolean,
|
|
||||||
geoName: string,
|
|
||||||
geoLatitude?: number | null,
|
|
||||||
geoLongitude?: number | null,
|
|
||||||
) => {
|
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setEditingNodeId(nodeId);
|
setEditingNode(node);
|
||||||
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),
|
|
||||||
});
|
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,12 +182,6 @@ export function NodesPage() {
|
|||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
deleteMutation.mutate(nodeId);
|
deleteMutation.mutate(nodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
setFeedback(null);
|
|
||||||
saveMutation.mutate(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -487,16 +366,7 @@ export function NodesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(node)}
|
||||||
handleEdit(
|
|
||||||
node.id,
|
|
||||||
node.name,
|
|
||||||
node.auto_update_enabled,
|
|
||||||
node.geo_name,
|
|
||||||
node.geo_latitude,
|
|
||||||
node.geo_longitude,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="px-3 py-2 text-xs"
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
@@ -520,98 +390,19 @@ export function NodesPage() {
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
<AppModal
|
<NodeEditorModal
|
||||||
isOpen={isEditorOpen}
|
isOpen={isEditorOpen}
|
||||||
|
node={editingNode}
|
||||||
|
isSubmitting={saveMutation.isPending}
|
||||||
|
title={editingNode ? '编辑节点' : '新增节点'}
|
||||||
onClose={handleReset}
|
onClose={handleReset}
|
||||||
title={editingNodeId ? '编辑节点' : '新增节点'}
|
|
||||||
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
||||||
footer={
|
submitLabel={editingNode ? '保存修改' : '新增节点'}
|
||||||
<div className="flex flex-wrap justify-end gap-3">
|
onSubmit={(payload) => {
|
||||||
<SecondaryButton
|
setFeedback(null);
|
||||||
type="button"
|
saveMutation.mutate(payload);
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface NodeItem {
|
|||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
geo_longitude?: number | null;
|
geo_longitude?: number | null;
|
||||||
|
geo_manual_override: boolean;
|
||||||
agent_token: string;
|
agent_token: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
update_requested: boolean;
|
update_requested: boolean;
|
||||||
@@ -39,10 +40,12 @@ export interface NodeBootstrapToken {
|
|||||||
|
|
||||||
export interface NodeMutationPayload {
|
export interface NodeMutationPayload {
|
||||||
name: string;
|
name: string;
|
||||||
|
ip: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
geo_longitude?: number | null;
|
geo_longitude?: number | null;
|
||||||
|
geo_manual_override: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeAgentReleaseInfo {
|
export interface NodeAgentReleaseInfo {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const defaultOperationFields = {
|
|||||||
AgentHeartbeatInterval: '10000',
|
AgentHeartbeatInterval: '10000',
|
||||||
NodeOfflineThreshold: '120000',
|
NodeOfflineThreshold: '120000',
|
||||||
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
||||||
GeoIPProvider: 'disabled',
|
GeoIPProvider: 'ipinfo',
|
||||||
OpenRestyWorkerProcesses: 'auto',
|
OpenRestyWorkerProcesses: 'auto',
|
||||||
OpenRestyWorkerConnections: '4096',
|
OpenRestyWorkerConnections: '4096',
|
||||||
OpenRestyWorkerRlimitNofile: '65535',
|
OpenRestyWorkerRlimitNofile: '65535',
|
||||||
@@ -339,7 +339,7 @@ export function SettingsPage() {
|
|||||||
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000',
|
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000',
|
||||||
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
|
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
|
||||||
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare',
|
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare',
|
||||||
GeoIPProvider: optionMap.GeoIPProvider ?? 'disabled',
|
GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo',
|
||||||
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
|
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
|
||||||
OpenRestyWorkerConnections:
|
OpenRestyWorkerConnections:
|
||||||
optionMap.OpenRestyWorkerConnections ?? '4096',
|
optionMap.OpenRestyWorkerConnections ?? '4096',
|
||||||
@@ -919,171 +919,147 @@ export function SettingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||||
<AppCard
|
<AppCard
|
||||||
title="接口文档与 Agent 运行参数"
|
title="Agent 接入设置"
|
||||||
description="这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。"
|
description="集中维护 Agent 运行参数、更新仓库与 IP 归属方式。运行参数会通过心跳响应下发到 Agent。"
|
||||||
action={
|
action={
|
||||||
<div className="flex flex-wrap gap-2">
|
<PrimaryButton
|
||||||
<SecondaryButton
|
type="button"
|
||||||
type="button"
|
onClick={() =>
|
||||||
onClick={() =>
|
void runBusyAction('operation-settings', async () => {
|
||||||
window.open(
|
const heartbeat = Number.parseInt(
|
||||||
'/swagger/index.html',
|
operationFields.AgentHeartbeatInterval,
|
||||||
'_blank',
|
10,
|
||||||
'noopener,noreferrer',
|
);
|
||||||
)
|
const offline = Number.parseInt(
|
||||||
}
|
operationFields.NodeOfflineThreshold,
|
||||||
>
|
10,
|
||||||
打开接口文档
|
);
|
||||||
</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
void runBusyAction('operation-intervals', async () => {
|
|
||||||
const heartbeat = Number.parseInt(
|
|
||||||
operationFields.AgentHeartbeatInterval,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const offline = Number.parseInt(
|
|
||||||
operationFields.NodeOfflineThreshold,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
|
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
|
||||||
throw new Error('心跳间隔不能小于 5000 毫秒。');
|
throw new Error('心跳间隔不能小于 5000 毫秒。');
|
||||||
}
|
}
|
||||||
if (Number.isNaN(offline) || offline < 10000) {
|
if (Number.isNaN(offline) || offline < 10000) {
|
||||||
throw new Error('离线阈值不能小于 10000 毫秒。');
|
throw new Error('离线阈值不能小于 10000 毫秒。');
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveOptionEntries(
|
await saveOptionEntries(
|
||||||
[
|
[
|
||||||
['AgentHeartbeatInterval', String(heartbeat)],
|
['AgentHeartbeatInterval', String(heartbeat)],
|
||||||
['NodeOfflineThreshold', String(offline)],
|
['NodeOfflineThreshold', String(offline)],
|
||||||
],
|
['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()],
|
||||||
'Agent 运行参数已保存。',
|
['GeoIPProvider', operationFields.GeoIPProvider],
|
||||||
);
|
],
|
||||||
})
|
'运维设置已保存。',
|
||||||
}
|
);
|
||||||
disabled={busyKey === 'operation-intervals'}
|
})
|
||||||
>
|
}
|
||||||
{busyKey === 'operation-intervals'
|
>
|
||||||
? '保存中...'
|
{busyKey === 'operation-settings' ? '保存中...' : '保存设置'}
|
||||||
: '保存运行参数'}
|
</PrimaryButton>
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="space-y-6">
|
||||||
<ResourceField
|
<div className="space-y-4">
|
||||||
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
|
<div className="space-y-1">
|
||||||
>
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
<ResourceInput
|
Agent 运行参数
|
||||||
type="number"
|
</p>
|
||||||
value={operationFields.AgentHeartbeatInterval}
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
onChange={(event) =>
|
修改后会在下个心跳周期同步到节点。
|
||||||
setOperationFields((previous) => ({
|
</p>
|
||||||
...previous,
|
</div>
|
||||||
AgentHeartbeatInterval: event.target.value,
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
}))
|
<ResourceField
|
||||||
}
|
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
|
||||||
/>
|
>
|
||||||
</ResourceField>
|
<ResourceInput
|
||||||
<ResourceField
|
type="number"
|
||||||
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
|
value={operationFields.AgentHeartbeatInterval}
|
||||||
>
|
onChange={(event) =>
|
||||||
<ResourceInput
|
setOperationFields((previous) => ({
|
||||||
type="number"
|
...previous,
|
||||||
value={operationFields.NodeOfflineThreshold}
|
AgentHeartbeatInterval: event.target.value,
|
||||||
onChange={(event) =>
|
}))
|
||||||
setOperationFields((previous) => ({
|
}
|
||||||
...previous,
|
/>
|
||||||
NodeOfflineThreshold: event.target.value,
|
</ResourceField>
|
||||||
}))
|
<ResourceField
|
||||||
}
|
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
|
||||||
/>
|
>
|
||||||
</ResourceField>
|
<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>
|
</div>
|
||||||
</AppCard>
|
</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
|
<AppCard
|
||||||
title="Discovery Token 与部署命令"
|
title="Discovery Token 与部署命令"
|
||||||
description="适用于新节点首次接入。可直接复制一键安装命令。"
|
description="适用于新节点首次接入。可直接复制一键安装命令。"
|
||||||
@@ -1275,25 +1251,39 @@ export function SettingsPage() {
|
|||||||
title="通用设置"
|
title="通用设置"
|
||||||
description="服务器地址会影响邮件链接、OAuth 回调和部署命令展示。"
|
description="服务器地址会影响邮件链接、OAuth 回调和部署命令展示。"
|
||||||
action={
|
action={
|
||||||
<PrimaryButton
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
<SecondaryButton
|
||||||
onClick={() =>
|
type="button"
|
||||||
void runBusyAction('system-general', async () => {
|
onClick={() =>
|
||||||
await saveOptionEntries(
|
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'}
|
||||||
disabled={busyKey === 'system-general'}
|
>
|
||||||
>
|
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
|
||||||
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
|
</PrimaryButton>
|
||||||
</PrimaryButton>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ResourceField label="服务器地址">
|
<ResourceField label="服务器地址">
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ volumes:
|
|||||||
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
|
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
|
||||||
| `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` |
|
| `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` |
|
||||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/ATSFlare` |
|
| `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` |
|
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口(秒) | `300` / `180` |
|
||||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` |
|
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` |
|
||||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` |
|
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` |
|
||||||
|
|||||||
Reference in New Issue
Block a user