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:
ryan
2026-03-14 15:50:02 +08:00
parent 4a50762092
commit f33e9514dc
18 changed files with 1411 additions and 943 deletions
+15
View File
@@ -89,6 +89,7 @@ type NodeView struct {
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override"`
AgentToken string `json:"agent_token"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
@@ -324,6 +325,7 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[
}
appendIfChanged("name", previous.Name, current.Name)
appendIfChanged("ip", previous.IP, current.IP)
appendIfChanged("geo_name", previous.GeoName, current.GeoName)
appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion)
appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion)
appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus)
@@ -335,8 +337,21 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[
appendIfChanged("update_channel", previous.UpdateChannel, current.UpdateChannel)
appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag)
appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested)
if !coordinatesEqual(previous.GeoLatitude, current.GeoLatitude) {
changes["geo_latitude"] = current.GeoLatitude
}
if !coordinatesEqual(previous.GeoLongitude, current.GeoLongitude) {
changes["geo_longitude"] = current.GeoLongitude
}
if !previous.LastSeenAt.Equal(current.LastSeenAt) {
changes["last_seen_at"] = current.LastSeenAt
}
return changes
}
func coordinatesEqual(before *float64, after *float64) bool {
if before == nil || after == nil {
return before == after
}
return *before == *after
}
+65 -9
View File
@@ -3,21 +3,25 @@ package service
import (
"atsflare/common"
"atsflare/model"
"atsflare/utils/geoip"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net"
"strings"
"time"
)
type NodeInput struct {
Name string `json:"name"`
IP string `json:"ip"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override"`
}
type NodeAgentUpdateInput struct {
@@ -50,16 +54,17 @@ type AgentRegistrationResponse struct {
}
func CreateNode(input NodeInput) (*NodeView, error) {
name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input)
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
if name == "" {
return nil, errors.New("节点名不能为空")
}
node := &model.Node{
Name: name,
IP: "",
IP: ip,
GeoName: geoName,
GeoLatitude: geoLatitude,
GeoLongitude: geoLongitude,
GeoManualOverride: geoManualOverride,
AgentVersion: "",
NginxVersion: "",
Status: NodeStatusPending,
@@ -73,6 +78,9 @@ func CreateNode(input NodeInput) (*NodeView, error) {
if err != nil {
return nil, err
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
if err := node.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("节点标识生成冲突,请重试")
@@ -84,7 +92,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
}
func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input)
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
if name == "" {
return nil, errors.New("节点名不能为空")
}
@@ -93,10 +101,15 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
return nil, err
}
node.Name = name
node.IP = ip
node.GeoName = geoName
node.GeoLatitude = geoLatitude
node.GeoLongitude = geoLongitude
node.GeoManualOverride = geoManualOverride
node.AutoUpdateEnabled = input.AutoUpdateEnabled
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
}
if err = node.Update(); err != nil {
return nil, err
}
@@ -242,6 +255,7 @@ func buildNodeView(node *model.Node) *NodeView {
GeoName: strings.TrimSpace(node.GeoName),
GeoLatitude: node.GeoLatitude,
GeoLongitude: node.GeoLongitude,
GeoManualOverride: node.GeoManualOverride,
AgentToken: node.AgentToken,
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
UpdateTag: strings.TrimSpace(node.UpdateTag),
@@ -265,26 +279,41 @@ func buildNodeView(node *model.Node) *NodeView {
return view
}
func normalizeNodeInput(input NodeInput) (string, string, *float64, *float64, error) {
func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *float64, bool, error) {
name := strings.TrimSpace(input.Name)
ip := strings.TrimSpace(input.IP)
geoName := strings.TrimSpace(input.GeoName)
manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil
if len(ip) > 64 {
return "", "", "", nil, nil, false, errors.New("节点 IP 不能超过 64 个字符")
}
if ip != "" && net.ParseIP(ip) == nil {
return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效")
}
if len(geoName) > 128 {
return "", "", nil, nil, errors.New("节点位置名不能超过 128 个字符")
return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符")
}
geoLatitude := cloneCoordinate(input.GeoLatitude)
geoLongitude := cloneCoordinate(input.GeoLongitude)
if (geoLatitude == nil) != (geoLongitude == nil) {
return "", "", nil, nil, errors.New("地图坐标必须同时填写纬度和经度")
return "", "", "", nil, nil, false, errors.New("地图坐标必须同时填写纬度和经度")
}
if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) {
return "", "", nil, nil, errors.New("纬度必须在 -90 到 90 之间")
return "", "", "", nil, nil, false, errors.New("纬度必须在 -90 到 90 之间")
}
if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) {
return "", "", nil, nil, errors.New("经度必须在 -180 到 180 之间")
return "", "", "", nil, nil, false, errors.New("经度必须在 -180 到 180 之间")
}
return name, geoName, geoLatitude, geoLongitude, nil
if !manualOverride {
return name, ip, "", nil, nil, false, nil
}
if geoLatitude == nil && geoLongitude == nil && geoName == "" {
return name, ip, "", nil, nil, false, nil
}
return name, ip, geoName, geoLatitude, geoLongitude, true, nil
}
func cloneCoordinate(value *float64) *float64 {
@@ -410,6 +439,33 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
node.LastSeenAt = time.Now()
node.LastError = strings.TrimSpace(payload.LastError)
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
}
func applyGeoInfoFromIP(node *model.Node, rawIP string) {
if node == nil {
return
}
node.GeoName = ""
node.GeoLatitude = nil
node.GeoLongitude = nil
ip := net.ParseIP(strings.TrimSpace(rawIP))
if ip == nil {
return
}
info, err := geoip.GetGeoInfo(ip)
if err != nil || info == nil {
return
}
if strings.TrimSpace(info.Name) != "" {
node.GeoName = strings.TrimSpace(info.Name)
}
if info.Latitude != nil && info.Longitude != nil {
node.GeoLatitude = cloneCoordinate(info.Latitude)
node.GeoLongitude = cloneCoordinate(info.Longitude)
}
}
func normalizeOpenrestyStatus(status string) string {
+178 -6
View File
@@ -3,7 +3,9 @@ package service
import (
"atsflare/common"
"atsflare/model"
"atsflare/utils/geoip"
"io"
"net"
"net/http"
"sort"
"strings"
@@ -13,20 +15,54 @@ import (
type roundTripFunc func(req *http.Request) (*http.Response, error)
type fakeGeoIPProvider struct {
info *geoip.GeoInfo
}
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func (f *fakeGeoIPProvider) Name() string {
return "fake-geoip"
}
func (f *fakeGeoIPProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) {
return f.info, nil
}
func (f *fakeGeoIPProvider) UpdateDatabase() error {
return nil
}
func (f *fakeGeoIPProvider) Close() error {
return nil
}
func withFakeGeoIPProvider(t *testing.T, info *geoip.GeoInfo) {
t.Helper()
previous := geoip.CurrentProvider
geoip.CurrentProvider = &fakeGeoIPProvider{info: info}
t.Cleanup(func() {
geoip.CurrentProvider = previous
})
}
func geoipFloat(value float64) *float64 {
return &value
}
func TestRequestNodeAgentPreviewUpdate(t *testing.T) {
setupServiceTestDB(t)
latitude := 31.2304
longitude := 121.4737
node, err := CreateNode(NodeInput{
Name: "preview-edge-1",
GeoName: "Shanghai",
GeoLatitude: &latitude,
GeoLongitude: &longitude,
Name: "preview-edge-1",
GeoManualOverride: true,
GeoName: "Shanghai",
GeoLatitude: &latitude,
GeoLongitude: &longitude,
})
if err != nil {
t.Fatalf("failed to create node: %v", err)
@@ -173,6 +209,7 @@ func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) {
updated, err := UpdateNode(node.ID, NodeInput{
Name: "geo-edge-updated",
AutoUpdateEnabled: true,
GeoManualOverride: true,
GeoName: "San Francisco",
GeoLatitude: &latitude,
GeoLongitude: &longitude,
@@ -203,13 +240,148 @@ func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) {
latitude := 37.7749
if _, err = UpdateNode(node.ID, NodeInput{
Name: "geo-edge-invalid",
GeoLatitude: &latitude,
Name: "geo-edge-invalid",
GeoManualOverride: true,
GeoLatitude: &latitude,
}); err == nil {
t.Fatal("expected partial geo metadata to be rejected")
}
}
func TestUpdateNodeRejectsInvalidIP(t *testing.T) {
setupServiceTestDB(t)
node, err := CreateNode(NodeInput{Name: "geo-edge-invalid-ip"})
if err != nil {
t.Fatalf("failed to create node: %v", err)
}
if _, err = UpdateNode(node.ID, NodeInput{
Name: "geo-edge-invalid-ip",
IP: "not-an-ip",
}); err == nil {
t.Fatal("expected invalid IP to be rejected")
}
}
func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) {
setupServiceTestDB(t)
withFakeGeoIPProvider(t, &geoip.GeoInfo{
ISOCode: "US",
Name: "United States",
Latitude: geoipFloat(37.7749),
Longitude: geoipFloat(-122.4194),
})
node, err := CreateNode(NodeInput{Name: "geo-edge-auto-ip"})
if err != nil {
t.Fatalf("failed to create node: %v", err)
}
updated, err := UpdateNode(node.ID, NodeInput{
Name: "geo-edge-auto-ip",
IP: "8.8.8.8",
})
if err != nil {
t.Fatalf("expected node update to succeed: %v", err)
}
if updated.IP != "8.8.8.8" {
t.Fatalf("expected updated IP to be persisted, got %+v", updated.IP)
}
if updated.GeoName != "United States" {
t.Fatalf("expected geo name to be auto resolved, got %+v", updated)
}
if updated.GeoLatitude == nil || updated.GeoLongitude == nil {
t.Fatalf("expected geo coordinates to be auto resolved, got %+v", updated)
}
}
func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
setupServiceTestDB(t)
withFakeGeoIPProvider(t, &geoip.GeoInfo{
ISOCode: "US",
Name: "United States",
Latitude: geoipFloat(37.7749),
Longitude: geoipFloat(-122.4194),
})
node := &model.Node{
NodeID: "node-geo-auto",
Name: "geo-auto",
IP: "10.0.0.8",
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
resp, err := HeartbeatNode(node, AgentNodePayload{
NodeID: node.NodeID,
Name: node.Name,
IP: "8.8.8.8",
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
})
if err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
}
if resp.Node.GeoName != "United States" {
t.Fatalf("expected auto geo name, got %+v", resp.Node)
}
if resp.Node.GeoLatitude == nil || resp.Node.GeoLongitude == nil {
t.Fatalf("expected auto geo coordinates, got %+v", resp.Node)
}
}
func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) {
setupServiceTestDB(t)
withFakeGeoIPProvider(t, &geoip.GeoInfo{
ISOCode: "US",
Name: "United States",
Latitude: geoipFloat(37.7749),
Longitude: geoipFloat(-122.4194),
})
latitude := 31.2304
longitude := 121.4737
node := &model.Node{
NodeID: "node-geo-manual",
Name: "geo-manual",
IP: "10.0.0.8",
GeoName: "Shanghai",
GeoLatitude: &latitude,
GeoLongitude: &longitude,
GeoManualOverride: true,
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
resp, err := HeartbeatNode(node, AgentNodePayload{
NodeID: node.NodeID,
Name: node.Name,
IP: "8.8.8.8",
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
})
if err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
}
if resp.Node.GeoName != "Shanghai" {
t.Fatalf("expected manual geo name to be preserved, got %+v", resp.Node)
}
if resp.Node.GeoLatitude == nil || *resp.Node.GeoLatitude != latitude {
t.Fatalf("expected manual latitude to be preserved, got %+v", resp.Node.GeoLatitude)
}
}
func TestRequestNodeOpenrestyRestart(t *testing.T) {
setupServiceTestDB(t)