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
+1 -1
View File
@@ -53,7 +53,7 @@ var NodeOfflineThreshold = 2 * time.Minute
// V3 operational settings (hot-reloadable via Option table)
var AgentHeartbeatInterval = 10000 // milliseconds
var AgentUpdateRepo = "Rain-kl/ATSFlare"
var GeoIPProvider = "disabled"
var GeoIPProvider = "ipinfo"
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyWorkerProcesses = "auto"
+1
View File
@@ -10,6 +10,7 @@ type Node struct {
GeoName string `json:"geo_name" gorm:"size:128"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override" gorm:"not null;default:false"`
AgentToken string `json:"-" gorm:"size:128;index"`
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
+10 -8
View File
@@ -184,10 +184,11 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}
createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
"name": "shanghai-edge-1",
"geo_name": "Shanghai",
"geo_latitude": 31.2304,
"geo_longitude": 121.4737,
"name": "shanghai-edge-1",
"geo_manual_override": true,
"geo_name": "Shanghai",
"geo_latitude": 31.2304,
"geo_longitude": 121.4737,
})
var createdNode service.NodeView
decodeResponseData(t, createdNodeResp, &createdNode)
@@ -330,10 +331,11 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
"name": "shanghai-edge-1-renamed",
"geo_name": "Tokyo",
"geo_latitude": 35.6762,
"geo_longitude": 139.6503,
"name": "shanghai-edge-1-renamed",
"geo_manual_override": true,
"geo_name": "Tokyo",
"geo_latitude": 35.6762,
"geo_longitude": 139.6503,
})
decodeResponseData(t, updatedNodeResp, &createdNode)
if createdNode.Name != "shanghai-edge-1-renamed" {
+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)
+8 -2
View File
@@ -24,8 +24,10 @@ const (
)
type GeoInfo struct {
ISOCode string
Name string
ISOCode string
Name string
Latitude *float64
Longitude *float64
}
func init() {
@@ -202,3 +204,7 @@ func getProvider() GeoIPService {
}
return CurrentProvider
}
func float64Pointer(value float64) *float64 {
return &value
}
+8 -4
View File
@@ -16,8 +16,10 @@ type GeoJSService struct {
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
// 我们只定义我们需要的字段。
type geoJSResponse struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Latitude float64 `json:"latitude,string"`
Longitude float64 `json:"longitude,string"`
// 可以根据需要添加其他字段,例如:
// City string `json:"city"`
// Region string `json:"region"`
@@ -64,8 +66,10 @@ func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
Latitude: float64Pointer(apiResp.Latitude),
Longitude: float64Pointer(apiResp.Longitude),
}, nil
}
+4 -2
View File
@@ -66,8 +66,10 @@ func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
Latitude: float64Pointer(apiResp.Lat),
Longitude: float64Pointer(apiResp.Lon),
}, nil
}
+23 -2
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
)
@@ -65,6 +67,8 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
}
latitude, longitude := parseIPInfoCoordinates(apiResp.Loc)
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
@@ -73,8 +77,10 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
return &GeoInfo{
ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code
Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称
ISOCode: apiResp.Country,
Name: apiResp.Country,
Latitude: latitude,
Longitude: longitude,
}, nil
}
@@ -89,3 +95,18 @@ func (s *IPInfoService) Close() error {
// 无需执行任何操作
return nil
}
func parseIPInfoCoordinates(value string) (*float64, *float64) {
parts := strings.Split(strings.TrimSpace(value), ",")
if len(parts) != 2 {
return nil, nil
}
latitudeValue, latErr := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
longitudeValue, lonErr := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if latErr != nil || lonErr != nil {
return nil, nil
}
return float64Pointer(latitudeValue), float64Pointer(longitudeValue)
}
@@ -0,0 +1,372 @@
'use client';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import { ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
} from '@/features/nodes/utils';
echarts.use([GeoComponent, TooltipComponent, ScatterChart, CanvasRenderer]);
const fallbackCoordinates = [
[-122.4194, 37.7749],
[-46.6333, -23.5505],
[-0.1276, 51.5072],
[2.3522, 48.8566],
[77.209, 28.6139],
[121.4737, 31.2304],
[103.8198, 1.3521],
[151.2093, -33.8688],
[28.0473, -26.2041],
[139.6917, 35.6895],
] as const;
type Tone = 'healthy' | 'warning' | 'danger';
type WorldGeoJson = {
type: string;
features?: unknown[];
};
type MapNodeDatum = {
id: number;
name: string;
geoName: string;
route: string;
derivedFromGeo: boolean;
requestCount: number;
errorCount: number;
activeEventCount: number;
status: DashboardNodeHealth['status'];
openrestyStatus: DashboardNodeHealth['openresty_status'];
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
};
emphasis: {
itemStyle: {
borderColor: string;
borderWidth: number;
};
};
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
return 'danger';
}
if (
node.cpu_usage_percent >= 80 ||
node.memory_usage_percent >= 85 ||
node.storage_usage_percent >= 85
) {
return 'warning';
}
return 'healthy';
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
return {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
derivedFromGeo: false,
};
}
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
return true;
} catch (error) {
const registrationError =
error instanceof Error ? error : new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', registrationError);
return false;
}
}
export function WorldStageMap({
isDark,
nodes,
}: {
isDark: boolean;
nodes: DashboardNodeHealth[];
}) {
const router = useRouter();
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.14)',
labelColor: '#e2e8f0',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
healthyBorder: '#d1fae5',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
},
[isDark],
);
const mapNodes = useMemo<MapNodeDatum[]>(
() =>
nodes.map((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
const tone = getNodeTone(node);
const toneColor =
tone === 'healthy'
? mapPalette.healthyColor
: tone === 'warning'
? mapPalette.warningColor
: mapPalette.dangerColor;
const toneBorder =
tone === 'healthy'
? mapPalette.healthyBorder
: tone === 'warning'
? mapPalette.warningBorder
: mapPalette.dangerBorder;
return {
id: node.id,
name: node.name,
geoName: node.geo_name || node.name,
route: buildNodeDetailHref(node.id),
derivedFromGeo,
requestCount: node.request_count,
errorCount: node.error_count,
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 1.5,
},
emphasis: {
itemStyle: {
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
transitionDuration: 0,
backgroundColor: isDark
? 'rgba(15,23,42,0.96)'
: 'rgba(255,255,255,0.96)',
borderColor: isDark
? 'rgba(148,163,184,0.2)'
: 'rgba(148,163,184,0.24)',
borderWidth: 1,
textStyle: {
color: isDark ? '#e2e8f0' : '#0f172a',
fontSize: 12,
},
formatter: (params: unknown) => {
const data = (params as { data?: MapNodeDatum }).data;
if (!data) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 预设落点`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
].join('');
},
},
geo: {
map: 'world',
roam: false,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: '132%',
zoom: 1.12,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.7,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
return Math.max(8, Math.min(18, 8 + Math.log10(size + 1) * 3.6));
},
label: {
show: false,
color: mapPalette.labelColor,
fontSize: 11,
},
emphasis: {
scale: 1.12,
label: {
show: mapNodes.length > 0 && mapNodes.length <= 5,
position: 'right',
distance: 8,
formatter: '{b}',
backgroundColor: isDark
? 'rgba(8,15,31,0.7)'
: 'rgba(255,255,255,0.88)',
borderColor: isDark
? 'rgba(148,163,184,0.16)'
: 'rgba(148,163,184,0.2)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
},
},
],
}),
[isDark, mapNodes, mapPalette],
);
if (!mapReady) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/>
</div>
);
}
return (
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
);
}
@@ -1,15 +1,11 @@
'use client';
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
DashboardConfig,
@@ -27,100 +23,15 @@ import {
import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
const fallbackCoordinates = [
[-122.4194, 37.7749],
[-46.6333, -23.5505],
[-0.1276, 51.5072],
[2.3522, 48.8566],
[77.209, 28.6139],
[121.4737, 31.2304],
[103.8198, 1.3521],
[151.2093, -33.8688],
[28.0473, -26.2041],
[139.6917, 35.6895],
] as const;
type Tone = 'healthy' | 'warning' | 'danger';
type MapNodeDatum = {
id: number;
name: string;
geoName: string;
route: string;
derivedFromGeo: boolean;
requestCount: number;
errorCount: number;
activeEventCount: number;
status: DashboardNodeHealth['status'];
openrestyStatus: DashboardNodeHealth['openresty_status'];
tone: Tone;
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
shadowBlur: number;
shadowColor: string;
};
emphasis: {
itemStyle: {
shadowBlur: number;
shadowColor: string;
borderColor: string;
borderWidth: number;
};
};
};
type WorldGeoJson = {
type: string;
features?: unknown[];
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
let worldMapRegistrationError: Error | null = null;
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
worldMapRegistrationError = null;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
worldMapRegistrationError = null;
return true;
} catch (error) {
worldMapRegistrationSucceeded = false;
worldMapRegistrationError =
error instanceof Error ? error : new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', worldMapRegistrationError);
return false;
}
}
const WorldStageMap = dynamic(
() =>
import('@/features/dashboard/components/world-stage-map').then(
(module) => module.WorldStageMap,
),
{ ssr: false },
);
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
@@ -136,46 +47,6 @@ function buildNodeDetailHref(id?: number | null) {
return `/node/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
return 'danger';
}
if (
node.cpu_usage_percent >= 80 ||
node.memory_usage_percent >= 85 ||
node.storage_usage_percent >= 85
) {
return 'warning';
}
return 'healthy';
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
return {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
derivedFromGeo: false,
};
}
function HeroMetric({
label,
value,
@@ -190,7 +61,7 @@ function HeroMetric({
return (
<div
className={cn(
'rounded-[24px] border px-4 py-4 backdrop-blur',
'rounded-[24px] border px-5 py-5 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
@@ -206,7 +77,7 @@ function HeroMetric({
</p>
<p
className={cn(
'mt-3 text-2xl font-semibold',
'mt-3 text-[30px] font-semibold leading-none',
isDark ? 'text-white' : 'text-slate-950',
)}
>
@@ -315,17 +186,38 @@ export function WorldStage({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
if (shouldRenderMap) {
return;
}
const mapViewport = mapViewportRef.current;
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
setShouldRenderMap(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldRenderMap(true);
observer.disconnect();
}
},
{ rootMargin: '180px 0px' },
);
observer.observe(mapViewport);
return () => {
observer.disconnect();
};
}, [shouldRenderMap]);
const onlineRate =
summary.total_nodes > 0
@@ -349,185 +241,6 @@ export function WorldStage({
typeof node.geo_longitude === 'number',
).length;
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.18)',
shadowColor: 'rgba(8,15,31,0.35)',
labelColor: '#e2e8f0',
subLabelColor: '#94a3b8',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.16)',
shadowColor: 'rgba(148,163,184,0.18)',
labelColor: '#0f172a',
subLabelColor: '#475569',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
healthyBorder: '#d1fae5',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
},
[isDark],
);
const mapNodes = useMemo<MapNodeDatum[]>(
() =>
nodes.map((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
const tone = getNodeTone(node);
const toneColor =
tone === 'healthy'
? mapPalette.healthyColor
: tone === 'warning'
? mapPalette.warningColor
: mapPalette.dangerColor;
const toneBorder =
tone === 'healthy'
? mapPalette.healthyBorder
: tone === 'warning'
? mapPalette.warningBorder
: mapPalette.dangerBorder;
return {
id: node.id,
name: node.name,
geoName: node.geo_name || node.name,
route: buildNodeDetailHref(node.id),
derivedFromGeo,
requestCount: node.request_count,
errorCount: node.error_count,
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
tone,
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 2,
shadowBlur: 18,
shadowColor: toneColor,
},
emphasis: {
itemStyle: {
shadowBlur: 24,
shadowColor: toneColor,
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const mapOption = useMemo<EChartsOption>(
() => ({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: isDark
? 'rgba(15,23,42,0.96)'
: 'rgba(255,255,255,0.96)',
borderColor: isDark
? 'rgba(148,163,184,0.2)'
: 'rgba(148,163,184,0.24)',
borderWidth: 1,
textStyle: {
color: isDark ? '#e2e8f0' : '#0f172a',
fontSize: 12,
},
formatter: (params: unknown) => {
const data = (params as { data?: MapNodeDatum }).data;
if (!data) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 备用落点`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求 ${data.requestCount.toLocaleString('zh-CN')} · 错误 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动异常 ${data.activeEventCount} · 节点 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
].join('');
},
},
geo: {
map: 'world',
roam: false,
silent: true,
regions: [],
top: 18,
bottom: 18,
left: 10,
right: 10,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.8,
shadowBlur: 12,
shadowColor: mapPalette.shadowColor,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'effectScatter',
coordinateSystem: 'geo',
data: mapNodes,
showEffectOn: 'render',
rippleEffect: {
scale: 4,
brushType: 'stroke',
},
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
return Math.max(10, Math.min(24, 10 + Math.log10(size + 1) * 4.5));
},
label: {
show: mapNodes.length <= 8,
position: 'right',
distance: 8,
formatter: '{b}',
color: mapPalette.labelColor,
fontSize: 11,
backgroundColor: isDark ? 'rgba(8,15,31,0.7)' : 'rgba(255,255,255,0.88)',
borderColor: isDark
? 'rgba(148,163,184,0.16)'
: 'rgba(148,163,184,0.2)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
emphasis: {
scale: true,
label: {
show: true,
},
},
},
],
}),
[isDark, mapNodes, mapPalette],
);
return (
<section
className={cn(
@@ -539,7 +252,7 @@ export function WorldStage({
>
<div
className={cn(
'border-b px-6 py-5 md:px-7',
'border-b px-6 py-6 md:px-7 md:py-7',
isDark ? 'border-white/8' : 'border-slate-200/70',
)}
>
@@ -567,8 +280,8 @@ export function WorldStage({
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
让总览页真正承担值守入口的职责。
在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源,
让首屏可以直接承担值守与研判入口。
</p>
</div>
<div
@@ -584,11 +297,11 @@ export function WorldStage({
</div>
</div>
<div className="grid gap-6 px-6 py-6 md:px-7 xl:grid-cols-[1.4fr_0.8fr]">
<div className="space-y-4">
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.4fr_0.8fr]">
<div className="space-y-5">
<div
className={cn(
'relative min-h-[420px] overflow-hidden rounded-[28px] border',
'relative min-h-[456px] overflow-hidden rounded-[28px] border py-5',
isDark
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
@@ -602,45 +315,34 @@ export function WorldStage({
: 'bg-sky-100/90 text-sky-700',
)}
>
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
</div>
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
<LegendPill label="绿色: 运行健康" tone="healthy" isDark={isDark} />
<LegendPill label="黄色: 容量压力" tone="warning" isDark={isDark} />
<LegendPill label="红色: 异常或离线" tone="danger" isDark={isDark} />
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
</div>
<div className="absolute inset-0">
{mapReady ? (
<ReactECharts
option={mapOption}
notMerge
lazyUpdate
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在准备 ECharts 世界地图与节点信号点位。'
}
/>
</div>
)}
<div
ref={mapViewportRef}
className="absolute inset-x-3 top-14 bottom-24 flex items-center justify-center md:inset-x-4 md:top-16 md:bottom-28"
>
<div className="aspect-[2/1] h-auto max-h-full w-full">
{shouldRenderMap ? (
<WorldStageMap isDark={isDark} nodes={nodes} />
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title="全球地图准备中"
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
/>
</div>
)}
</div>
</div>
{mapReady && nodes.length === 0 ? (
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-24 z-10">
<div
className={cn(
@@ -650,12 +352,12 @@ export function WorldStage({
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
</div>
</div>
) : null}
<div className="absolute bottom-4 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
<div className="absolute bottom-5 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
{sourceCountries.length > 0 ? (
sourceCountries.slice(0, 3).map((item, index) => (
<CountrySignal
@@ -674,7 +376,7 @@ export function WorldStage({
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前还没有可用于全球分布展示的来源国家数据。
暂无可用于全球来源展示的国家分布数据。
</div>
)}
</div>
@@ -682,41 +384,41 @@ export function WorldStage({
<div
className={cn(
'rounded-[24px] border px-4 py-3 text-xs leading-6 backdrop-blur',
'rounded-[24px] border px-5 py-4 text-xs leading-6 backdrop-blur',
isDark
? 'border-white/10 bg-white/5 text-slate-300'
: 'border-slate-200/80 bg-white/80 text-slate-600',
)}
>
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。
未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。
</div>
</div>
<div className="space-y-4">
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<HeroMetric
label="在线覆盖"
value={formatPercent(onlineRate)}
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
isDark={isDark}
/>
<HeroMetric
label="运行健康"
value={formatPercent(healthyRate)}
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
isDark={isDark}
/>
<HeroMetric
label="配置追平"
value={formatPercent(syncRate)}
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
hint={`${summary.lagging_nodes} 个节点未对齐 ${config.active_version || '当前激活版本'}`}
isDark={isDark}
/>
<HeroMetric
label="活动风险"
value={summary.active_alerts.toLocaleString('zh-CN')}
hint={`${risk.critical_alerts} Critical · ${risk.warning_alerts} Warning`}
hint={`${risk.critical_alerts} 个严重告警 · ${risk.warning_alerts} 个预警`}
isDark={isDark}
/>
</div>
@@ -737,7 +439,7 @@ export function WorldStage({
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
节点健康清单
节点风险清单
</p>
<p
className={cn(
@@ -745,7 +447,7 @@ export function WorldStage({
isDark ? 'text-white' : 'text-slate-950',
)}
>
风险优先队列
优先处置节点
</p>
</div>
<Link
@@ -788,7 +490,7 @@ export function WorldStage({
isDark ? 'text-slate-400' : 'text-slate-500',
)}
>
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
请求量 {node.request_count.toLocaleString('zh-CN')} · 错误数{' '}
{node.error_count.toLocaleString('zh-CN')}
</p>
</div>
@@ -816,7 +518,7 @@ export function WorldStage({
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
)}
>
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
</div>
) : null}
</div>
@@ -2,11 +2,8 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
@@ -31,9 +28,9 @@ import {
requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
import type {
NodeAgentReleaseInfo,
NodeMutationPayload,
NodeObservability,
} from '@/features/nodes/types';
import {
@@ -43,7 +40,6 @@ import {
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import type { ReleaseChannel } from '@/features/update/types';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
@@ -62,17 +58,6 @@ import {
const nodesQueryKey = ['nodes'];
const nodeSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
auto_update_enabled: z.boolean(),
});
type NodeFormValues = z.infer<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
@@ -80,32 +65,10 @@ type FeedbackState = {
type HealthEventFilter = 'all' | 'active' | 'resolved';
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(
values: NodeFormValues,
currentNode?: {
geo_name?: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
} | null,
): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_name: currentNode?.geo_name ?? '',
geo_latitude: currentNode?.geo_latitude ?? null,
geo_longitude: currentNode?.geo_longitude ?? null,
};
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
@@ -303,16 +266,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
@@ -364,27 +317,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}
}, [serverUrl]);
useEffect(() => {
if (!node || isEditorOpen) {
return;
}
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
}, [form, isEditorOpen, node]);
const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) =>
updateNode(Number(nodeId), toPayload(values, node)),
mutationFn: async (payload: Parameters<typeof updateNode>[1]) =>
updateNode(Number(nodeId), payload),
onSuccess: async (updatedNode) => {
setFeedback({ tone: 'success', message: '节点已更新。' });
setIsEditorOpen(false);
form.reset({
name: updatedNode.name,
auto_update_enabled: updatedNode.auto_update_enabled,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
@@ -445,11 +383,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
},
});
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleDelete = () => {
if (!node) {
return;
@@ -1697,59 +1630,19 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
onClose={() => setIsTargetSnapshotOpen(false)}
/>
<AppModal
<NodeEditorModal
isOpen={isEditorOpen}
node={node}
isSubmitting={saveMutation.isPending}
onClose={() => setIsEditorOpen(false)}
title="编辑"
title="编辑节点"
description="更新模式和节点名都在详情页维护。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(false)}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-detail-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="node-detail-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</form>
</AppModal>
submitLabel="保存修改"
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
<AppModal
isOpen={isAgentUpdateModalOpen}
@@ -0,0 +1,438 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMemo, useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { AppModal } from '@/components/ui/app-modal';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry;
properties?: {
name?: string;
};
};
type RegionOption = {
label: string;
latitude: number;
longitude: number;
};
const nodeEditorSchema = z
.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_region: z.string(),
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
geo_latitude: z.string().trim(),
geo_longitude: z.string().trim(),
})
.superRefine((values, ctx) => {
if (!values.geo_manual_override) {
return;
}
const hasLatitude = values.geo_latitude !== '';
const hasLongitude = values.geo_longitude !== '';
if (hasLatitude !== hasLongitude) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度和经度需要同时填写',
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '纬度和经度需要同时填写',
});
return;
}
if (hasLatitude) {
const latitude = Number(values.geo_latitude);
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度必须在 -90 到 90 之间',
});
}
}
if (hasLongitude) {
const longitude = Number(values.geo_longitude);
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '经度必须在 -180 到 180 之间',
});
}
}
});
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
const defaultValues: NodeEditorValues = {
name: '',
ip: '',
auto_update_enabled: false,
geo_manual_override: false,
geo_region: '',
geo_name: '',
geo_latitude: '',
geo_longitude: '',
};
function collectPoints(value: unknown, points: Array<[number, number]>) {
if (!Array.isArray(value)) {
return;
}
if (
value.length >= 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number'
) {
points.push([value[0], value[1]]);
return;
}
for (const item of value) {
collectPoints(item, points);
}
}
function getRegionCenter(feature: GeoJsonFeature) {
const points: Array<[number, number]> = [];
collectPoints(feature.geometry?.coordinates, points);
if (points.length === 0) {
return null;
}
let minLongitude = Number.POSITIVE_INFINITY;
let maxLongitude = Number.NEGATIVE_INFINITY;
let minLatitude = Number.POSITIVE_INFINITY;
let maxLatitude = Number.NEGATIVE_INFINITY;
for (const [longitude, latitude] of points) {
minLongitude = Math.min(minLongitude, longitude);
maxLongitude = Math.max(maxLongitude, longitude);
minLatitude = Math.min(minLatitude, latitude);
maxLatitude = Math.max(maxLatitude, latitude);
}
return {
longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)),
latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)),
};
}
function buildRegionOptions() {
const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ??
[]) as GeoJsonFeature[];
const options = new Map<string, RegionOption>();
for (const feature of features) {
const label = feature.properties?.name?.trim();
if (!label || options.has(label)) {
continue;
}
const center = getRegionCenter(feature);
if (!center) {
continue;
}
options.set(label, {
label,
latitude: center.latitude,
longitude: center.longitude,
});
}
return Array.from(options.values()).sort((a, b) =>
a.label.localeCompare(b.label),
);
}
function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
if (!node) {
return defaultValues;
}
return {
name: node.name ?? '',
ip: node.ip ?? '',
auto_update_enabled: node.auto_update_enabled ?? false,
geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? node.geo_name ?? '' : '',
geo_name: node.geo_name ?? '',
geo_latitude:
node.geo_latitude === undefined || node.geo_latitude === null
? ''
: String(node.geo_latitude),
geo_longitude:
node.geo_longitude === undefined || node.geo_longitude === null
? ''
: String(node.geo_longitude),
};
}
function toPayload(values: NodeEditorValues): NodeMutationPayload {
if (!values.geo_manual_override) {
return {
name: values.name.trim(),
ip: values.ip.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_manual_override: false,
geo_name: '',
geo_latitude: null,
geo_longitude: null,
};
}
return {
name: values.name.trim(),
ip: values.ip.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_manual_override: true,
geo_name: values.geo_name.trim(),
geo_latitude:
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
geo_longitude:
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
};
}
export function NodeEditorModal({
isOpen,
node,
isSubmitting,
title,
description,
submitLabel,
onClose,
onSubmit,
}: {
isOpen: boolean;
node?: Partial<NodeItem> | null;
isSubmitting: boolean;
title: string;
description: string;
submitLabel: string;
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => void;
}) {
const form = useForm<NodeEditorValues>({
resolver: zodResolver(nodeEditorSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const watchedGeoManualOverride = useWatch({
control: form.control,
name: 'geo_manual_override',
});
const regionOptions = useMemo(() => buildRegionOptions(), []);
useEffect(() => {
if (!isOpen) {
return;
}
form.reset(buildFormValues(node));
}, [form, isOpen, node]);
const handleSubmit = form.handleSubmit((values) => {
onSubmit(toPayload(values));
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onClose}
disabled={isSubmitting}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-editor-form"
disabled={isSubmitting}
>
{isSubmitting ? '保存中...' : submitLabel}
</PrimaryButton>
</div>
}
>
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ResourceField
label="节点 IP"
hint="可手动维护节点当前对外 IP;留空则等待 Agent 注册或心跳自动回填。"
error={form.formState.errors.ip?.message}
>
<ResourceInput placeholder="203.0.113.10" {...form.register('ip')} />
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<ToggleField
label="手动指定地图地区"
description="关闭时,节点会根据当前 IP 自动解析归属地;开启后使用你手动选择的地区与坐标。"
checked={watchedGeoManualOverride}
onChange={(checked) => {
form.setValue('geo_manual_override', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('geo_region', '', { shouldDirty: true });
form.setValue('geo_name', '', { shouldDirty: true });
form.setValue('geo_latitude', '', { shouldDirty: true });
form.setValue('geo_longitude', '', { shouldDirty: true });
}
}}
/>
{watchedGeoManualOverride ? (
<ResourceField
label="地区选择"
hint="选择后会自动填充位置名与地图坐标,你也可以继续微调。"
>
<ResourceSelect
value={form.watch('geo_region')}
onChange={(event) => {
const regionName = event.target.value;
form.setValue('geo_region', regionName, {
shouldDirty: true,
});
const selectedRegion = regionOptions.find(
(item) => item.label === regionName,
);
if (!selectedRegion) {
return;
}
form.setValue('geo_name', selectedRegion.label, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('geo_latitude', String(selectedRegion.latitude), {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
'geo_longitude',
String(selectedRegion.longitude),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
>
<option value="">请选择地区</option>
{regionOptions.map((option) => (
<option key={option.label} value={option.label}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
) : null}
<ResourceField
label="地图位置名"
hint={
watchedGeoManualOverride
? '可在自动填充后继续修改,例如使用更贴近业务的展示名称。'
: '自动解析模式下,该字段会由系统根据节点 IP 回填。'
}
error={form.formState.errors.geo_name?.message}
>
<ResourceInput
placeholder="Shanghai"
disabled={!watchedGeoManualOverride}
{...form.register('geo_name')}
/>
</ResourceField>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="纬度"
hint="范围 -90 到 90,例如上海约为 31.2304"
error={form.formState.errors.geo_latitude?.message}
>
<ResourceInput
placeholder="31.2304"
disabled={!watchedGeoManualOverride}
{...form.register('geo_latitude')}
/>
</ResourceField>
<ResourceField
label="经度"
hint="范围 -180 到 180,例如上海约为 121.4737"
error={form.formState.errors.geo_longitude?.message}
>
<ResourceInput
placeholder="121.4737"
disabled={!watchedGeoManualOverride}
{...form.register('geo_longitude')}
/>
</ResourceField>
</div>
</form>
</AppModal>
);
}
@@ -2,18 +2,14 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
@@ -23,14 +19,11 @@ import {
getNodes,
updateNode,
} from '@/features/nodes/api/nodes';
import type { NodeMutationPayload } from '@/features/nodes/types';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
@@ -53,105 +46,22 @@ const supportedRiskFilters = [
type NodeRiskFilter = (typeof supportedRiskFilters)[number];
const nodeSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
auto_update_enabled: z.boolean(),
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
geo_latitude: z.string().trim(),
geo_longitude: z.string().trim(),
}).superRefine((values, ctx) => {
const hasLatitude = values.geo_latitude !== '';
const hasLongitude = values.geo_longitude !== '';
if (hasLatitude !== hasLongitude) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度和经度需要同时填写',
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '纬度和经度需要同时填写',
});
return;
}
if (hasLatitude) {
const latitude = Number(values.geo_latitude);
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度必须在 -90 到 90 之间',
});
}
}
if (hasLongitude) {
const longitude = Number(values.geo_longitude);
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '经度必须在 -180 到 180 之间',
});
}
}
});
type NodeFormValues = z.infer<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
geo_name: '',
geo_latitude: '',
geo_longitude: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(values: NodeFormValues): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_name: values.geo_name.trim(),
geo_latitude:
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
geo_longitude:
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
};
}
export function NodesPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
@@ -165,20 +75,18 @@ export function NodesPage() {
});
const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) => {
const payload = toPayload(values);
return editingNodeId
? updateNode(editingNodeId, payload)
mutationFn: async (payload: NodeMutationPayload) => {
return editingNode
? updateNode(editingNode.id, payload)
: createNode(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。',
message: editingNode ? '节点已更新。' : '节点已创建。',
});
setEditingNodeId(null);
setEditingNode(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
@@ -190,8 +98,7 @@ export function NodesPage() {
mutationFn: deleteNode,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNodeId(null);
form.reset(defaultValues);
setEditingNode(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
@@ -247,41 +154,19 @@ export function NodesPage() {
const handleReset = () => {
setFeedback(null);
setEditingNodeId(null);
setEditingNode(null);
setIsEditorOpen(false);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingNodeId(null);
form.reset(defaultValues);
setEditingNode(null);
setIsEditorOpen(true);
};
const handleEdit = (
nodeId: number,
name: string,
autoUpdateEnabled: boolean,
geoName: string,
geoLatitude?: number | null,
geoLongitude?: number | null,
) => {
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNodeId(nodeId);
form.reset({
name,
auto_update_enabled: autoUpdateEnabled,
geo_name: geoName,
geo_latitude:
geoLatitude === undefined || geoLatitude === null
? ''
: String(geoLatitude),
geo_longitude:
geoLongitude === undefined || geoLongitude === null
? ''
: String(geoLongitude),
});
setEditingNode(node);
setIsEditorOpen(true);
};
@@ -297,12 +182,6 @@ export function NodesPage() {
setFeedback(null);
deleteMutation.mutate(nodeId);
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
return (
<>
<div className="space-y-6">
@@ -487,16 +366,7 @@ export function NodesPage() {
</Link>
<SecondaryButton
type="button"
onClick={() =>
handleEdit(
node.id,
node.name,
node.auto_update_enabled,
node.geo_name,
node.geo_latitude,
node.geo_longitude,
)
}
onClick={() => handleEdit(node)}
className="px-3 py-2 text-xs"
>
编辑
@@ -520,98 +390,19 @@ export function NodesPage() {
)}
</AppCard>
</div>
<AppModal
<NodeEditorModal
isOpen={isEditorOpen}
node={editingNode}
isSubmitting={saveMutation.isPending}
title={editingNode ? '编辑节点' : '新增节点'}
onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'}
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleReset}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingNodeId
? '保存修改'
: '新增节点'}
</PrimaryButton>
</div>
}
>
<form
id="node-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<ResourceField
label="地图位置名"
hint="示例:Shanghai / Tokyo / Frankfurt,可用于总览世界板标注。"
error={form.formState.errors.geo_name?.message}
>
<ResourceInput
placeholder="Shanghai"
{...form.register('geo_name')}
/>
</ResourceField>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="纬度"
hint="范围 -90 到 90,例如上海约为 31.2304"
error={form.formState.errors.geo_latitude?.message}
>
<ResourceInput
placeholder="31.2304"
{...form.register('geo_latitude')}
/>
</ResourceField>
<ResourceField
label="经度"
hint="范围 -180 到 180,例如上海约为 121.4737"
error={form.formState.errors.geo_longitude?.message}
>
<ResourceInput
placeholder="121.4737"
{...form.register('geo_longitude')}
/>
</ResourceField>
</div>
</form>
</AppModal>
submitLabel={editingNode ? '保存修改' : '新增节点'}
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
</>
);
}
+3
View File
@@ -8,6 +8,7 @@ export interface NodeItem {
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
agent_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
@@ -39,10 +40,12 @@ export interface NodeBootstrapToken {
export interface NodeMutationPayload {
name: string;
ip: string;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
@@ -72,7 +72,7 @@ const defaultOperationFields = {
AgentHeartbeatInterval: '10000',
NodeOfflineThreshold: '120000',
AgentUpdateRepo: 'Rain-kl/ATSFlare',
GeoIPProvider: 'disabled',
GeoIPProvider: 'ipinfo',
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
OpenRestyWorkerRlimitNofile: '65535',
@@ -339,7 +339,7 @@ export function SettingsPage() {
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000',
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare',
GeoIPProvider: optionMap.GeoIPProvider ?? 'disabled',
GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo',
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
OpenRestyWorkerConnections:
optionMap.OpenRestyWorkerConnections ?? '4096',
@@ -919,171 +919,147 @@ export function SettingsPage() {
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
<AppCard
title="接口文档与 Agent 运行参数"
description="这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。"
title="Agent 接入设置"
description="集中维护 Agent 运行参数、更新仓库与 IP 归属方式。运行参数会通过心跳响应下发到 Agent。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() =>
window.open(
'/swagger/index.html',
'_blank',
'noopener,noreferrer',
)
}
>
打开接口文档
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('operation-intervals', async () => {
const heartbeat = Number.parseInt(
operationFields.AgentHeartbeatInterval,
10,
);
const offline = Number.parseInt(
operationFields.NodeOfflineThreshold,
10,
);
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('operation-settings', async () => {
const heartbeat = Number.parseInt(
operationFields.AgentHeartbeatInterval,
10,
);
const offline = Number.parseInt(
operationFields.NodeOfflineThreshold,
10,
);
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
throw new Error('心跳间隔不能小于 5000 毫秒。');
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error('离线阈值不能小于 10000 毫秒。');
}
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
throw new Error('心跳间隔不能小于 5000 毫秒。');
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error('离线阈值不能小于 10000 毫秒。');
}
await saveOptionEntries(
[
['AgentHeartbeatInterval', String(heartbeat)],
['NodeOfflineThreshold', String(offline)],
],
'Agent 运行参数已保存。',
);
})
}
disabled={busyKey === 'operation-intervals'}
>
{busyKey === 'operation-intervals'
? '保存中...'
: '保存运行参数'}
</PrimaryButton>
</div>
await saveOptionEntries(
[
['AgentHeartbeatInterval', String(heartbeat)],
['NodeOfflineThreshold', String(offline)],
['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()],
['GeoIPProvider', operationFields.GeoIPProvider],
],
'运维设置已保存。',
);
})
}
>
{busyKey === 'operation-settings' ? '保存中...' : '保存设置'}
</PrimaryButton>
}
>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
>
<ResourceInput
type="number"
value={operationFields.AgentHeartbeatInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
AgentHeartbeatInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
>
<ResourceInput
type="number"
value={operationFields.NodeOfflineThreshold}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
NodeOfflineThreshold: event.target.value,
}))
}
/>
</ResourceField>
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
Agent 运行参数
</p>
<p className="text-sm text-[var(--foreground-muted)]">
修改后会在下个心跳周期同步到节点。
</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}
>
<ResourceInput
type="number"
value={operationFields.AgentHeartbeatInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
AgentHeartbeatInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}
>
<ResourceInput
type="number"
value={operationFields.NodeOfflineThreshold}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
NodeOfflineThreshold: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
<div className="border-t border-[var(--border-default)] pt-6">
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
Agent 更新仓库
</p>
<p className="text-sm text-[var(--foreground-muted)]">
自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。
</p>
</div>
<div className="mt-4">
<ResourceField label="GitHub 仓库">
<ResourceInput
value={operationFields.AgentUpdateRepo}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
AgentUpdateRepo: event.target.value,
}))
}
placeholder="Rain-kl/ATSFlare"
/>
</ResourceField>
</div>
</div>
<div className="border-t border-[var(--border-default)] pt-6">
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
IP 归属方式
</p>
<p className="text-sm text-[var(--foreground-muted)]">
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。
</p>
</div>
<div className="mt-4">
<ResourceField
label="归属方式"
hint="disabled 关闭解析;mmdb 使用本地数据库;其余选项调用外部 GeoIP 服务。"
>
<ResourceSelect
value={operationFields.GeoIPProvider}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
GeoIPProvider: event.target.value,
}))
}
>
<option value="disabled">关闭</option>
<option value="mmdb">MaxMind mmdb</option>
<option value="ip-api">ip-api.com</option>
<option value="geojs">geojs.io</option>
<option value="ipinfo">ipinfo.io</option>
</ResourceSelect>
</ResourceField>
</div>
</div>
</div>
</AppCard>
<AppCard
title="Agent 更新仓库"
description="自动更新和手动更新动作在节点页触发,这里只维护 Agent 自更新时使用的仓库地址。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('operation-repo', async () => {
await saveOptionEntries(
[
[
'AgentUpdateRepo',
operationFields.AgentUpdateRepo.trim(),
],
],
'Agent 更新仓库已保存。',
);
})
}
disabled={busyKey === 'operation-repo'}
>
{busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'}
</PrimaryButton>
}
>
<ResourceField label="GitHub 仓库">
<ResourceInput
value={operationFields.AgentUpdateRepo}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
AgentUpdateRepo: event.target.value,
}))
}
placeholder="Rain-kl/ATSFlare"
/>
</ResourceField>
</AppCard>
<AppCard
title="IP 归属方式"
description="控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('operation-geoip', async () => {
await saveOptionEntries(
[['GeoIPProvider', operationFields.GeoIPProvider]],
'IP 归属方式已保存。',
);
})
}
disabled={busyKey === 'operation-geoip'}
>
{busyKey === 'operation-geoip' ? '保存中...' : '保存归属方式'}
</PrimaryButton>
}
>
<ResourceField
label="归属方式"
hint="disabled 关闭解析;mmdb 使用本地数据库;其余选项调用外部 GeoIP 服务。"
>
<ResourceSelect
value={operationFields.GeoIPProvider}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
GeoIPProvider: event.target.value,
}))
}
>
<option value="disabled">关闭</option>
<option value="mmdb">MaxMind mmdb</option>
<option value="ip-api">ip-api.com</option>
<option value="geojs">geojs.io</option>
<option value="ipinfo">ipinfo.io</option>
</ResourceSelect>
</ResourceField>
</AppCard>
<AppCard
title="Discovery Token 与部署命令"
description="适用于新节点首次接入。可直接复制一键安装命令。"
@@ -1275,25 +1251,39 @@ export function SettingsPage() {
title="通用设置"
description="服务器地址会影响邮件链接、OAuth 回调和部署命令展示。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('system-general', async () => {
await saveOptionEntries(
[
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() =>
window.open(
'/swagger/index.html',
'_blank',
'noopener,noreferrer',
)
}
>
打开接口文档
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('system-general', async () => {
await saveOptionEntries(
[
'ServerAddress',
normalizeServerUrl(systemFields.ServerAddress),
[
'ServerAddress',
normalizeServerUrl(systemFields.ServerAddress),
],
],
],
'通用设置已保存。',
);
})
}
disabled={busyKey === 'system-general'}
>
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
</PrimaryButton>
'通用设置已保存。',
);
})
}
disabled={busyKey === 'system-general'}
>
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
</PrimaryButton>
</div>
}
>
<ResourceField label="服务器地址">
+1 -1
View File
@@ -111,7 +111,7 @@ volumes:
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
| `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/ATSFlare` |
| `GeoIPProvider` | IP 归属解析方式;支持 `disabled`、`mmdb`、`ip-api`、`geojs`、`ipinfo` | `disabled` |
| `GeoIPProvider` | IP 归属解析方式;支持 `disabled`、`mmdb`、`ip-api`、`geojs`、`ipinfo` | `ipinfo` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口(秒) | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` |