From fc3db065db325d2298879818958272312aa85c62 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 14 Mar 2026 12:59:22 +0800 Subject: [PATCH] feat: add geo metadata to nodes and dashboard - Extend DashboardNodeHealth and NodeItem interfaces to include geo_name, geo_latitude, and geo_longitude. - Update NodeDetailPage and NodesPage components to handle geo metadata in forms and payloads. - Implement validation for geo fields in NodesPage using Zod. - Enhance dashboard overview tests to include geo metadata for nodes. - Create WorldStage component to visualize node health and geo locations on a world map. - Update design and development documentation to reflect the addition of geo metadata for nodes. --- atsf_server/model/node.go | 3 + atsf_server/router/api_phase2_test.go | 16 +- atsf_server/service/agent.go | 3 + atsf_server/service/dashboard.go | 6 + atsf_server/service/node.go | 51 +- atsf_server/service/node_update_test.go | 71 +- .../components/dashboard-overview.tsx | 699 ++++++++++++------ .../dashboard/components/world-stage.tsx | 379 ++++++++++ atsf_server/web/features/dashboard/types.ts | 3 + .../nodes/components/node-detail-page.tsx | 14 +- .../features/nodes/components/nodes-page.tsx | 103 +++ atsf_server/web/features/nodes/types.ts | 6 + .../tests/unit/dashboard-overview.test.tsx | 60 +- docs/design.md | 2 + docs/development-guidelines.md | 2 + docs/development-plan.md | 8 +- 16 files changed, 1188 insertions(+), 238 deletions(-) create mode 100644 atsf_server/web/features/dashboard/components/world-stage.tsx diff --git a/atsf_server/model/node.go b/atsf_server/model/node.go index 0232cea7..0d9abb4b 100644 --- a/atsf_server/model/node.go +++ b/atsf_server/model/node.go @@ -7,6 +7,9 @@ type Node struct { NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"` Name string `json:"name" gorm:"size:128;not null"` IP string `json:"ip" gorm:"size:64;not null"` + GeoName string `json:"geo_name" gorm:"size:128"` + GeoLatitude *float64 `json:"geo_latitude"` + GeoLongitude *float64 `json:"geo_longitude"` AgentToken string `json:"-" gorm:"size:128;index"` AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"` UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"` diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index e7084f24..98f137ce 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -184,13 +184,19 @@ func TestPhase2AgentLifecycle(t *testing.T) { } createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{ - "name": "shanghai-edge-1", + "name": "shanghai-edge-1", + "geo_name": "Shanghai", + "geo_latitude": 31.2304, + "geo_longitude": 121.4737, }) var createdNode service.NodeView decodeResponseData(t, createdNodeResp, &createdNode) if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending { t.Fatal("expected created node to expose agent token with pending status") } + if createdNode.GeoName != "Shanghai" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil { + t.Fatalf("expected created node to expose geo metadata, got %+v", createdNode) + } heartbeatPayload := map[string]any{ "node_id": "spoofed-node-id", @@ -324,12 +330,18 @@ 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", + "name": "shanghai-edge-1-renamed", + "geo_name": "Tokyo", + "geo_latitude": 35.6762, + "geo_longitude": 139.6503, }) decodeResponseData(t, updatedNodeResp, &createdNode) if createdNode.Name != "shanghai-edge-1-renamed" { t.Fatal("expected node name to be editable") } + if createdNode.GeoName != "Tokyo" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil { + t.Fatalf("expected node geo metadata to be editable, got %+v", createdNode) + } oldTime := time.Now().Add(-common.NodeOfflineThreshold - time.Minute) if err := model.DB.Model(&model.Node{}).Where("node_id = ?", createdNode.NodeID).Update("last_seen_at", oldTime).Error; err != nil { diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index 769ce897..e0ed2a5a 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -86,6 +86,9 @@ type NodeView struct { NodeID string `json:"node_id"` Name string `json:"name"` IP string `json:"ip"` + GeoName string `json:"geo_name"` + GeoLatitude *float64 `json:"geo_latitude"` + GeoLongitude *float64 `json:"geo_longitude"` AgentToken string `json:"agent_token"` AutoUpdateEnabled bool `json:"auto_update_enabled"` UpdateRequested bool `json:"update_requested"` diff --git a/atsf_server/service/dashboard.go b/atsf_server/service/dashboard.go index 5b976f03..849bcbaf 100644 --- a/atsf_server/service/dashboard.go +++ b/atsf_server/service/dashboard.go @@ -102,6 +102,9 @@ type DashboardNodeHealth struct { ID uint `json:"id"` NodeID string `json:"node_id"` Name string `json:"name"` + GeoName string `json:"geo_name"` + GeoLatitude *float64 `json:"geo_latitude"` + GeoLongitude *float64 `json:"geo_longitude"` Status string `json:"status"` OpenrestyStatus string `json:"openresty_status"` CurrentVersion string `json:"current_version"` @@ -204,6 +207,9 @@ func GetDashboardOverview() (*DashboardOverviewView, error) { ID: node.ID, NodeID: node.NodeID, Name: node.Name, + GeoName: node.GeoName, + GeoLatitude: node.GeoLatitude, + GeoLongitude: node.GeoLongitude, Status: computedStatus, OpenrestyStatus: node.OpenrestyStatus, CurrentVersion: node.CurrentVersion, diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go index 3a5830c9..ceab1264 100644 --- a/atsf_server/service/node.go +++ b/atsf_server/service/node.go @@ -13,8 +13,11 @@ import ( ) type NodeInput struct { - Name string `json:"name"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` + Name string `json:"name"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + GeoName string `json:"geo_name"` + GeoLatitude *float64 `json:"geo_latitude"` + GeoLongitude *float64 `json:"geo_longitude"` } type NodeAgentUpdateInput struct { @@ -47,19 +50,21 @@ type AgentRegistrationResponse struct { } func CreateNode(input NodeInput) (*NodeView, error) { - name := strings.TrimSpace(input.Name) + name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input) if name == "" { return nil, errors.New("节点名不能为空") } node := &model.Node{ Name: name, IP: "", + GeoName: geoName, + GeoLatitude: geoLatitude, + GeoLongitude: geoLongitude, AgentVersion: "", NginxVersion: "", Status: NodeStatusPending, AutoUpdateEnabled: input.AutoUpdateEnabled, } - var err error node.NodeID, err = newServerNodeID() if err != nil { return nil, err @@ -79,7 +84,7 @@ func CreateNode(input NodeInput) (*NodeView, error) { } func UpdateNode(id uint, input NodeInput) (*NodeView, error) { - name := strings.TrimSpace(input.Name) + name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input) if name == "" { return nil, errors.New("节点名不能为空") } @@ -88,6 +93,9 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) { return nil, err } node.Name = name + node.GeoName = geoName + node.GeoLatitude = geoLatitude + node.GeoLongitude = geoLongitude node.AutoUpdateEnabled = input.AutoUpdateEnabled if err = node.Update(); err != nil { return nil, err @@ -231,6 +239,9 @@ func buildNodeView(node *model.Node) *NodeView { NodeID: node.NodeID, Name: node.Name, IP: node.IP, + GeoName: strings.TrimSpace(node.GeoName), + GeoLatitude: node.GeoLatitude, + GeoLongitude: node.GeoLongitude, AgentToken: node.AgentToken, UpdateChannel: strings.TrimSpace(node.UpdateChannel), UpdateTag: strings.TrimSpace(node.UpdateTag), @@ -254,6 +265,36 @@ func buildNodeView(node *model.Node) *NodeView { return view } +func normalizeNodeInput(input NodeInput) (string, string, *float64, *float64, error) { + name := strings.TrimSpace(input.Name) + geoName := strings.TrimSpace(input.GeoName) + if len(geoName) > 128 { + return "", "", nil, nil, errors.New("节点位置名不能超过 128 个字符") + } + + geoLatitude := cloneCoordinate(input.GeoLatitude) + geoLongitude := cloneCoordinate(input.GeoLongitude) + if (geoLatitude == nil) != (geoLongitude == nil) { + return "", "", nil, nil, errors.New("地图坐标必须同时填写纬度和经度") + } + if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) { + return "", "", nil, nil, errors.New("纬度必须在 -90 到 90 之间") + } + if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) { + return "", "", nil, nil, errors.New("经度必须在 -180 到 180 之间") + } + + return name, geoName, geoLatitude, geoLongitude, nil +} + +func cloneCoordinate(value *float64) *float64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo { currentVersion := strings.TrimSpace(node.AgentVersion) view := &NodeAgentReleaseInfo{ diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index 7745d498..cf87e9fc 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -20,10 +20,20 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { func TestRequestNodeAgentPreviewUpdate(t *testing.T) { setupServiceTestDB(t) - node, err := CreateNode(NodeInput{Name: "preview-edge-1"}) + latitude := 31.2304 + longitude := 121.4737 + node, err := CreateNode(NodeInput{ + Name: "preview-edge-1", + GeoName: "Shanghai", + GeoLatitude: &latitude, + GeoLongitude: &longitude, + }) if err != nil { t.Fatalf("failed to create node: %v", err) } + if node.GeoName != "Shanghai" || node.GeoLatitude == nil || node.GeoLongitude == nil { + t.Fatalf("expected geo metadata to be returned, got %+v", node) + } originalClient := UpdateHTTPClientForTest() SetUpdateHTTPClientForTest(&http.Client{ @@ -150,6 +160,56 @@ func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) { } } +func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) { + setupServiceTestDB(t) + + node, err := CreateNode(NodeInput{Name: "geo-edge"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + latitude := 37.7749 + longitude := -122.4194 + updated, err := UpdateNode(node.ID, NodeInput{ + Name: "geo-edge-updated", + AutoUpdateEnabled: true, + GeoName: "San Francisco", + GeoLatitude: &latitude, + GeoLongitude: &longitude, + }) + if err != nil { + t.Fatalf("expected node update to succeed: %v", err) + } + if updated.GeoName != "San Francisco" || updated.GeoLatitude == nil || updated.GeoLongitude == nil { + t.Fatalf("expected geo metadata in view, got %+v", updated) + } + + stored, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to load node: %v", err) + } + if stored.GeoName != "San Francisco" || stored.GeoLatitude == nil || stored.GeoLongitude == nil { + t.Fatalf("expected geo metadata persisted, got %+v", stored) + } +} + +func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) { + setupServiceTestDB(t) + + node, err := CreateNode(NodeInput{Name: "geo-edge-invalid"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + latitude := 37.7749 + if _, err = UpdateNode(node.ID, NodeInput{ + Name: "geo-edge-invalid", + GeoLatitude: &latitude, + }); err == nil { + t.Fatal("expected partial geo metadata to be rejected") + } +} + func TestRequestNodeOpenrestyRestart(t *testing.T) { setupServiceTestDB(t) @@ -176,6 +236,7 @@ func TestListNodeViewsIncludesLatestApplyLogsForMultipleNodes(t *testing.T) { NodeID: "node-a", Name: "edge-a", IP: "10.0.0.11", + GeoName: "Shanghai", AgentToken: "token-a", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", @@ -225,6 +286,9 @@ func TestListNodeViewsIncludesLatestApplyLogsForMultipleNodes(t *testing.T) { if views[0].NodeID != "node-a" || views[0].LatestApplyResult != ApplyResultFailed || views[0].LatestApplyMessage != "latest failure" { t.Fatalf("unexpected latest apply log for node-a: %+v", views[0]) } + if views[0].GeoName != "Shanghai" { + t.Fatalf("expected geo name to be exposed on node view, got %+v", views[0]) + } if views[1].NodeID != "node-b" || views[1].LatestApplyResult != ApplyResultOK || views[1].LatestApplyMessage != "latest success" { t.Fatalf("unexpected latest apply log for node-b: %+v", views[1]) } @@ -658,6 +722,7 @@ func TestGetDashboardOverview(t *testing.T) { NodeID: "node-dashboard-a", Name: "edge-a", IP: "10.0.0.71", + GeoName: "Shanghai", AgentToken: "token-a", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", @@ -670,6 +735,7 @@ func TestGetDashboardOverview(t *testing.T) { NodeID: "node-dashboard-b", Name: "edge-b", IP: "10.0.0.72", + GeoName: "San Francisco", AgentToken: "token-b", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", @@ -782,6 +848,9 @@ func TestGetDashboardOverview(t *testing.T) { if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 { t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts) } + if view.Nodes[0].GeoName == "" && view.Nodes[1].GeoName == "" { + t.Fatalf("expected dashboard nodes to expose geo metadata: %+v", view.Nodes) + } if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 || len(view.Trends.Network24h) != 24 || len(view.Trends.DiskIO24h) != 24 { t.Fatalf("expected 24-point dashboard trends, got %+v", view.Trends) } diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 0f6cb701..f9cfbab9 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; +import { RankChart } from '@/components/data/rank-chart'; import { TrendChart } from '@/components/data/trend-chart'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; @@ -15,6 +16,7 @@ import type { DashboardNodeHealth, DashboardPeakNode, } from '@/features/dashboard/types'; +import { WorldStage } from '@/features/dashboard/components/world-stage'; import { getNodeStatusLabel, getNodeStatusVariant, @@ -30,6 +32,21 @@ function formatPercent(value?: number | null) { return `${value.toFixed(1)}%`; } +function formatBytes(value?: number | null) { + if (!value || value <= 0) { + return '—'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let current = value; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`; +} + function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } @@ -42,6 +59,20 @@ function formatTrendHour(value: string) { return `${date.getHours().toString().padStart(2, '0')}:00`; } +function formatPeakHour(value: string) { + if (!value) { + return '暂无'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '暂无'; + } + return `${formatTrendHour(value)} - ${date + .getMinutes() + .toString() + .padStart(2, '0')}`; +} + function getAlertVariant( alert: DashboardAlert, ): 'success' | 'warning' | 'danger' | 'info' { @@ -57,18 +88,89 @@ function getAlertVariant( return 'info'; } -function formatPeakHour(value: string) { - if (!value) { - return '暂无'; +function formatPeakNode(node: DashboardPeakNode | null) { + if (!node) { + return { + title: '暂无', + hint: '当前没有可用节点数据', + }; } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '暂无'; + return { + title: node.node_name, + hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`, + }; +} + +function buildNodeDetailHref(id?: number | null) { + if (!id) { + return '/node'; } - return `${formatTrendHour(value)} - ${date - .getMinutes() - .toString() - .padStart(2, '0')}`; + return `/node/detail?id=${id}`; +} + +function buildNodeRankItems( + nodes: DashboardNodeHealth[], + selector: (node: DashboardNodeHealth) => number, + limit = 5, +) { + return [...nodes] + .sort((left, right) => { + const leftValue = selector(left); + const rightValue = selector(right); + if (leftValue === rightValue) { + return left.name.localeCompare(right.name, 'zh-CN'); + } + return rightValue - leftValue; + }) + .slice(0, limit) + .filter((node) => selector(node) > 0) + .map((node) => ({ + label: node.name, + value: selector(node), + })); +} + +function OverviewMetric({ + label, + value, + hint, + accent = 'sky', +}: { + label: string; + value: string | number; + hint?: string; + accent?: 'sky' | 'amber' | 'emerald' | 'rose'; +}) { + const accentClass = + accent === 'amber' + ? 'from-amber-400/14 to-transparent' + : accent === 'emerald' + ? 'from-emerald-400/14 to-transparent' + : accent === 'rose' + ? 'from-rose-400/14 to-transparent' + : 'from-sky-400/14 to-transparent'; + + return ( +
+
+
+

+ {label} +

+

+ {value} +

+ {hint ? ( +

+ {hint} +

+ ) : null} +
+
+
+ ); } function RiskSignal({ @@ -86,23 +188,25 @@ function RiskSignal({ }) { const toneClass = tone === 'danger' - ? 'border-rose-400/30 bg-rose-500/10 text-rose-100' + ? 'border-rose-400/28 bg-rose-500/10 text-rose-100' : tone === 'warning' - ? 'border-amber-400/30 bg-amber-500/10 text-amber-100' + ? 'border-amber-400/28 bg-amber-500/10 text-amber-100' : tone === 'success' - ? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100' - : 'border-sky-400/30 bg-sky-500/10 text-sky-100'; + ? 'border-emerald-400/28 bg-emerald-500/10 text-emerald-100' + : 'border-sky-400/28 bg-sky-500/10 text-sky-100'; const content = (
-

{label}

+

{label}

{value}

{hint}

); + if (!href) { return content; } + return ( {content} @@ -145,52 +249,6 @@ function PeakCard({ ); } -function formatPeakNode(node: DashboardPeakNode | null) { - if (!node) { - return { - title: '暂无', - hint: '当前没有可用节点数据', - }; - } - return { - title: node.node_name, - hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`, - }; -} - -function buildNodeDetailHref(id?: number | null) { - if (!id) { - return '/node'; - } - return `/node/detail?id=${id}`; -} - -function OverviewMetric({ - label, - value, - hint, -}: { - label: string; - value: string | number; - hint?: string; -}) { - return ( -
-

- {label} -

-

- {value} -

- {hint ? ( -

- {hint} -

- ) : null} -
- ); -} - function NodeHealthRow({ node }: { node: DashboardNodeHealth }) { return (

- {node.request_count} + {node.request_count.toLocaleString('zh-CN')}

@@ -302,50 +360,98 @@ export function DashboardOverview() { return (
- - 生成时间:{formatDateTime(overview.generated_at)} -
- } - > -
- - - - - -
- +
+ + + + + + +
+
+ + +
+ + + + +
+ +
-
- - -
- - - - -
-
@@ -497,99 +559,290 @@ export function DashboardOverview() {
-
+
- 进入节点页 - - } + title="24 小时网络与磁盘趋势" + description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。" > - {overview.nodes.length === 0 ? ( +
+ + formatTrendHour(point.bucket_started_at), + )} + height={180} + series={[ + { + label: 'OpenResty 入站', + color: '#22c55e', + fillColor: 'rgba(34, 197, 94, 0.14)', + variant: 'area', + values: overview.trends.network_24h.map( + (point) => point.openresty_rx_bytes, + ), + valueFormatter: formatBytes, + }, + { + label: 'OpenResty 出站', + color: '#38bdf8', + values: overview.trends.network_24h.map( + (point) => point.openresty_tx_bytes, + ), + valueFormatter: formatBytes, + }, + ]} + /> + + + formatTrendHour(point.bucket_started_at), + )} + height={180} + series={[ + { + label: '磁盘读', + color: '#a78bfa', + fillColor: 'rgba(167, 139, 250, 0.14)', + variant: 'area', + values: overview.trends.disk_io_24h.map( + (point) => point.disk_read_bytes, + ), + valueFormatter: formatBytes, + }, + { + label: '磁盘写', + color: '#fb7185', + values: overview.trends.disk_io_24h.map( + (point) => point.disk_write_bytes, + ), + valueFormatter: formatBytes, + }, + ]} + /> +
+
+ + + {overview.active_alerts.length === 0 ? ( ) : ( -
- {overview.nodes.slice(0, 8).map((node) => ( - +
+ {overview.active_alerts.map((alert) => ( + +
+ +

+ {alert.node_name} +

+
+

+ {alert.message || '暂无详细消息'} +

+

+ {formatRelativeTime(alert.last_triggered_at)} ·{' '} + {formatDateTime(alert.last_triggered_at)} +

+ ))}
)} +
-
- -
- - + + ({ + label: item.key, + value: item.value, + }))} + color="#38bdf8" + emptyMessage="暂无来源分布数据" + /> + + + + ({ + label: `HTTP ${item.key}`, + value: item.value, + }))} + color="#f59e0b" + emptyMessage="暂无状态码分布" + /> + + + + ({ + label: item.key, + value: item.value, + }))} + color="#34d399" + emptyMessage="暂无域名分布" + /> + +
+ +
+ +
+
+

+ 流量最高节点 +

+ node.request_count, )} - hint={`${overview.capacity.high_memory_nodes} 个高内存节点`} - /> -
- - - - {overview.active_alerts.length === 0 ? ( - +

+ 错误热点节点 +

+ node.error_count, + )} + color="#f97316" + emptyMessage="暂无错误热点" /> - ) : ( -
- {overview.active_alerts.map((alert) => ( - -
- -

- {alert.node_name} -

-
-

- {alert.message || '暂无详细消息'} -

-

- {formatRelativeTime(alert.last_triggered_at)} ·{' '} - {formatDateTime(alert.last_triggered_at)} -

- - ))} -
- )} -
-
+
+
+

+ 容量压力节点 +

+ + Math.round( + Math.max( + node.cpu_usage_percent, + node.memory_usage_percent, + node.storage_usage_percent, + ), + ), + )} + color="#ef4444" + valueFormatter={(value) => `${value}%`} + emptyMessage="暂无容量压力数据" + /> +
+
+
+ + +
+
+

+ 第一优先级 +

+

+ 先处理活动异常与 OpenResty 不健康节点 +

+

+ 当前有 {overview.summary.active_alerts} 个活动异常,{` `} + {overview.summary.unhealthy_nodes} 个 OpenResty 不健康节点。 +

+
+ +
+

+ 第二优先级 +

+

+ 检查配置落后与容量逼近节点 +

+

+ 当前有 {overview.summary.lagging_nodes} 个配置落后节点,{` `} + {overview.capacity.high_cpu_nodes + + overview.capacity.high_memory_nodes + + overview.capacity.high_storage_nodes}{' '} + 个节点出现明显容量压力。 +

+
+ +
+

+ 观察项 +

+

+ 关注峰值时段与来源分布变化 +

+

+ 请求峰值出现在 {formatPeakHour( + overview.peaks.peak_request_hour.bucket_started_at, + )} + ,当前主要来源集中在 + {overview.distributions.source_countries + .slice(0, 2) + .map((item) => item.key) + .join(' / ') || ' 暂无来源数据'} + 。 +

+
+
+
+ + + 进入节点页 + + } + > + {overview.nodes.length === 0 ? ( + + ) : ( +
+ {overview.nodes.slice(0, 8).map((node) => ( + + ))} +
+ )} +
); } diff --git a/atsf_server/web/features/dashboard/components/world-stage.tsx b/atsf_server/web/features/dashboard/components/world-stage.tsx new file mode 100644 index 00000000..d9725968 --- /dev/null +++ b/atsf_server/web/features/dashboard/components/world-stage.tsx @@ -0,0 +1,379 @@ +'use client'; + +import Link from 'next/link'; + +import { StatusBadge } from '@/components/ui/status-badge'; +import type { + DashboardConfig, + DashboardNodeHealth, + DashboardRiskSummary, + DashboardSummary, + DistributionItem, +} from '@/features/dashboard/types'; +import { + getNodeStatusLabel, + getNodeStatusVariant, + getOpenrestyStatusLabel, + getOpenrestyStatusVariant, +} from '@/features/nodes/utils'; +import { formatDateTime } from '@/lib/utils/date'; + +const stageAnchors = [ + { left: '16%', top: '24%' }, + { left: '24%', top: '58%' }, + { left: '45%', top: '22%' }, + { left: '52%', top: '54%' }, + { left: '72%', top: '30%' }, + { left: '84%', top: '68%' }, +]; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function formatPercent(value: number) { + if (!Number.isFinite(value)) { + return '0%'; + } + return `${value.toFixed(value >= 100 ? 0 : 1)}%`; +} + +function buildNodeDetailHref(id?: number | null) { + if (!id) { + return '/node'; + } + return `/node/detail?id=${id}`; +} + +function projectNodeCoordinates(node: DashboardNodeHealth, index: number) { + if ( + typeof node.geo_latitude === 'number' && + typeof node.geo_longitude === 'number' + ) { + const left = clamp(((node.geo_longitude + 180) / 360) * 100, 8, 92); + const top = clamp(((90 - node.geo_latitude) / 180) * 100, 12, 78); + return { + left: `${left}%`, + top: `${top}%`, + derivedFromGeo: true, + }; + } + + return { + ...stageAnchors[index % stageAnchors.length], + derivedFromGeo: false, + }; +} + +function getNodeSignalTone(node: DashboardNodeHealth) { + if ( + node.status === 'offline' || + node.openresty_status === 'unhealthy' || + node.active_event_count > 0 + ) { + return { + dot: 'bg-rose-400 shadow-[0_0_18px_rgba(251,113,133,0.75)]', + ring: 'border-rose-400/50 bg-rose-500/12 text-rose-50', + halo: 'bg-rose-500/20', + }; + } + + if ( + node.cpu_usage_percent >= 80 || + node.memory_usage_percent >= 85 || + node.storage_usage_percent >= 85 + ) { + return { + dot: 'bg-amber-300 shadow-[0_0_18px_rgba(252,211,77,0.75)]', + ring: 'border-amber-300/45 bg-amber-400/12 text-amber-50', + halo: 'bg-amber-400/18', + }; + } + + return { + dot: 'bg-emerald-300 shadow-[0_0_18px_rgba(110,231,183,0.7)]', + ring: 'border-emerald-300/40 bg-emerald-400/12 text-emerald-50', + halo: 'bg-emerald-400/18', + }; +} + +function HeroMetric({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

+ {label} +

+

{value}

+

{hint}

+
+ ); +} + +function CountrySignal({ + item, + index, +}: { + item: DistributionItem; + index: number; +}) { + const accents = [ + 'from-sky-400/35 to-cyan-400/10', + 'from-violet-400/35 to-fuchsia-400/10', + 'from-emerald-400/35 to-teal-400/10', + ]; + + return ( +
+

+ {item.key} +

+

+ {item.value.toLocaleString('zh-CN')} +

+

最近 24 小时来源信号

+
+ ); +} + +export function WorldStage({ + generatedAt, + summary, + risk, + config, + nodes, + sourceCountries, +}: { + generatedAt: string; + summary: DashboardSummary; + risk: DashboardRiskSummary; + config: DashboardConfig; + nodes: DashboardNodeHealth[]; + sourceCountries: DistributionItem[]; +}) { + const onlineRate = + summary.total_nodes > 0 + ? (summary.online_nodes / summary.total_nodes) * 100 + : 0; + const syncedNodes = Math.max( + 0, + summary.total_nodes - summary.lagging_nodes - summary.pending_nodes, + ); + const syncRate = + summary.total_nodes > 0 ? (syncedNodes / summary.total_nodes) * 100 : 0; + const healthyNodes = Math.max( + 0, + summary.online_nodes - summary.unhealthy_nodes - risk.offline_nodes, + ); + const healthyRate = + summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0; + const geoConfiguredNodes = nodes.filter( + (node) => + typeof node.geo_latitude === 'number' && + typeof node.geo_longitude === 'number', + ).length; + const signalNodes = nodes.slice(0, Math.max(stageAnchors.length, 8)); + + return ( +
+
+
+
+

+ Global Stage +

+

全球态势板

+

+ 先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏, + 让总览页真正承担值守入口的职责。 +

+
+
+ 数据生成于 {formatDateTime(generatedAt)} +
+
+
+ +
+
+
+
+
+ {geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'} +
+ + + + {signalNodes.map((node, index) => { + const anchor = projectNodeCoordinates(node, index); + const tone = getNodeSignalTone(node); + return ( + +
+
+ + +
+
+

+ {node.name} +

+

+ {(node.geo_name || node.name) + ' · '} + 请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '} + {node.active_event_count} +

+ {!anchor.derivedFromGeo ? ( +

+ 未配置经纬度,当前使用备用落点 +

+ ) : null} +
+
+ + ); + })} + +
+ {sourceCountries.length > 0 ? ( + sourceCountries.slice(0, 3).map((item, index) => ( + + )) + ) : ( +
+ 当前还没有可用于全球分布展示的来源国家数据。 +
+ )} +
+
+ +
+ 当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。 + 未配置坐标的节点仍会使用备用落点,不影响首页态势判断。 +
+
+ +
+
+ + + + +
+ +
+
+
+

+ 节点健康清单 +

+

+ 风险优先队列 +

+
+ + 查看全部节点 + +
+
+ {nodes.slice(0, 4).map((node) => ( + +
+
+

+ {node.name} +

+

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

+
+
+ + +
+
+ + ))} + {nodes.length === 0 ? ( +
+ 当前没有节点健康数据,等节点开始 heartbeat 后这里会出现世界覆盖信号。 +
+ ) : null} +
+
+
+
+
+ ); +} diff --git a/atsf_server/web/features/dashboard/types.ts b/atsf_server/web/features/dashboard/types.ts index ea552542..a4dee4df 100644 --- a/atsf_server/web/features/dashboard/types.ts +++ b/atsf_server/web/features/dashboard/types.ts @@ -118,6 +118,9 @@ export interface DashboardNodeHealth { id: number; node_id: string; name: string; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; status: 'online' | 'offline' | 'pending'; openresty_status: 'healthy' | 'unhealthy' | 'unknown'; current_version: string; diff --git a/atsf_server/web/features/nodes/components/node-detail-page.tsx b/atsf_server/web/features/nodes/components/node-detail-page.tsx index 9974d672..6e66752d 100644 --- a/atsf_server/web/features/nodes/components/node-detail-page.tsx +++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx @@ -89,10 +89,20 @@ function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } -function toPayload(values: NodeFormValues): NodeMutationPayload { +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, }; } @@ -345,7 +355,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const saveMutation = useMutation({ mutationFn: async (values: NodeFormValues) => - updateNode(Number(nodeId), toPayload(values)), + updateNode(Number(nodeId), toPayload(values, node)), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: '节点已更新。' }); setIsEditorOpen(false); diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx index 43a0a2f2..47b975aa 100644 --- a/atsf_server/web/features/nodes/components/nodes-page.tsx +++ b/atsf_server/web/features/nodes/components/nodes-page.tsx @@ -60,6 +60,48 @@ const nodeSchema = z.object({ .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; @@ -72,6 +114,9 @@ type FeedbackState = { const defaultValues: NodeFormValues = { name: '', auto_update_enabled: false, + geo_name: '', + geo_latitude: '', + geo_longitude: '', }; function getErrorMessage(error: unknown) { @@ -82,6 +127,11 @@ 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), }; } @@ -213,12 +263,24 @@ export function NodesPage() { nodeId: number, name: string, autoUpdateEnabled: boolean, + geoName: string, + geoLatitude?: number | null, + geoLongitude?: number | null, ) => { 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), }); setIsEditorOpen(true); }; @@ -368,6 +430,9 @@ export function NodesPage() {

IP:{node.ip || 'null'}

+

+ 位置:{node.geo_name || '未配置地图点位'} +

@@ -427,6 +492,9 @@ export function NodesPage() { node.id, node.name, node.auto_update_enabled, + node.geo_name, + node.geo_latitude, + node.geo_longitude, ) } className="px-3 py-2 text-xs" @@ -507,6 +575,41 @@ export function NodesPage() { }) } /> + + + + + +
+ + + + + + + +
diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts index 5248a6c6..176f255d 100644 --- a/atsf_server/web/features/nodes/types.ts +++ b/atsf_server/web/features/nodes/types.ts @@ -5,6 +5,9 @@ export interface NodeItem { node_id: string; name: string; ip: string; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; agent_token: string; auto_update_enabled: boolean; update_requested: boolean; @@ -37,6 +40,9 @@ export interface NodeBootstrapToken { export interface NodeMutationPayload { name: string; auto_update_enabled: boolean; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; } export interface NodeAgentReleaseInfo { diff --git a/atsf_server/web/tests/unit/dashboard-overview.test.tsx b/atsf_server/web/tests/unit/dashboard-overview.test.tsx index 440ef301..53bbb41b 100644 --- a/atsf_server/web/tests/unit/dashboard-overview.test.tsx +++ b/atsf_server/web/tests/unit/dashboard-overview.test.tsx @@ -98,6 +98,22 @@ describe('DashboardOverview', () => { storage_usage_percent: 95, }, }, + distributions: { + source_countries: [ + { key: 'CN', value: 440 }, + { key: 'US', value: 320 }, + { key: 'SG', value: 140 }, + ], + status_codes: [ + { key: '200', value: 820 }, + { key: '502', value: 24 }, + { key: '500', value: 12 }, + ], + top_domains: [ + { key: 'app.example.com', value: 580 }, + { key: 'api.example.com', value: 220 }, + ], + }, trends: { traffic_24h: Array.from({ length: 24 }, (_, index) => ({ bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, @@ -111,12 +127,29 @@ describe('DashboardOverview', () => { average_memory_usage_percent: index + 10, reported_nodes: 2, })), + network_24h: Array.from({ length: 24 }, (_, index) => ({ + bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, + network_rx_bytes: index * 100, + network_tx_bytes: index * 120, + openresty_rx_bytes: index * 140, + openresty_tx_bytes: index * 160, + reported_nodes: 2, + })), + disk_io_24h: Array.from({ length: 24 }, (_, index) => ({ + bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, + disk_read_bytes: index * 50, + disk_write_bytes: index * 70, + reported_nodes: 2, + })), }, nodes: [ { id: 1, node_id: 'node-a', name: 'edge-a', + geo_name: 'Shanghai', + geo_latitude: 31.2304, + geo_longitude: 121.4737, status: 'online', openresty_status: 'healthy', current_version: '20260314-001', @@ -129,6 +162,25 @@ describe('DashboardOverview', () => { error_count: 6, unique_visitor_count: 120, }, + { + id: 2, + node_id: 'node-b', + name: 'edge-b', + geo_name: 'San Francisco', + geo_latitude: 37.7749, + geo_longitude: -122.4194, + status: 'online', + openresty_status: 'unhealthy', + current_version: '20260313-001', + last_seen_at: '2026-03-14T08:00:00Z', + active_event_count: 2, + cpu_usage_percent: 92, + memory_usage_percent: 88, + storage_usage_percent: 95, + request_count: 300, + error_count: 30, + unique_visitor_count: 80, + }, ], active_alerts: [ { @@ -165,9 +217,13 @@ describe('DashboardOverview', () => { , ); - expect(await screen.findByText('系统运行总览')).toBeInTheDocument(); - expect(await screen.findByText('风险态势')).toBeInTheDocument(); + expect(await screen.findByText('全球态势板')).toBeInTheDocument(); + expect(await screen.findByText('系统健康摘要')).toBeInTheDocument(); expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument(); + expect(await screen.findByText('来源分布')).toBeInTheDocument(); + expect(await screen.findByText('Top Domain')).toBeInTheDocument(); + expect(await screen.findByText('Top 节点榜单')).toBeInTheDocument(); + expect(await screen.findByText('处置建议')).toBeInTheDocument(); expect(await screen.findByText('节点健康列表')).toBeInTheDocument(); }); }); diff --git a/docs/design.md b/docs/design.md index aad2da87..56507a49 100644 --- a/docs/design.md +++ b/docs/design.md @@ -79,6 +79,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部 * 第六版的数据分析目标聚焦当前反代链路的运营观测:QPS、访问次数、访问人数、访问来源分布、访问趋势、状态码分布,以及节点级 CPU、内存、存储、磁盘 IO、入站/出站流量 * 首页总览需要能回答“系统整体是否健康、容量是否逼近阈值、流量是否异常、是否存在配置未追平节点”这四类核心问题,可借鉴 WAF/安全大屏的信息组织方式,但不扩展为安全运营平台 * 世界地图看板仅用于展示节点分布与节点状态,不扩展为 GeoDNS、调度、路由编排或全球流量调度系统 +* 为了支持世界地图看板,允许节点维护低频地图元数据,如位置名、纬度和经度;这类字段仍属于控制面摘要信息,可保留在 `nodes` * 节点详情页聚焦系统信息、实时资源、网络流量、24 小时趋势和目标版本,不继续堆积低价值静态字段 * 在不引入 Prometheus、ClickHouse、Kafka 等新基础设施前提下完成第六版;数据采集、聚合与查询继续落在现有 Server/SQLite 基线内 * 原始请求数据不作为长期日志平台对外开放;第六版允许保留受控时间窗口内的明细,用于聚合分析、趋势计算与节点详情辅助排查 @@ -152,6 +153,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update) 第六版观测分层: * `nodes` 只承担节点身份、接入凭证、配置状态、运行控制状态等控制面字段 +* `nodes` 允许附带少量低频地图展示字段,如位置名、纬度和经度,用于总览世界看板真实落点 * `node_system_profiles` 承担低频变化的节点系统画像,如操作系统、内核、架构、CPU 型号、逻辑核数、内存总量、磁盘布局、启动时间与 Agent 能力声明 * `node_metric_snapshots` 承担周期性运行快照,如 CPU、负载、内存、文件系统占用、磁盘 IO、OpenResty 连接数与入站/出站吞吐 * `node_request_reports` 承担最近心跳窗口内的请求批次或受控明细,不作为长期日志平台 diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 6cb5810c..8f9b78a7 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -147,6 +147,7 @@ * 域名证书匹配必须同时支持精确匹配与通配符匹配 * 节点专属 `agent_token` 必须可立即失效 * `nodes` 只保留控制面与摘要状态,不直接承接第六版新增的高频资源字段、完整系统画像和大块统计结果 +* `nodes` 允许保留世界地图展示所需的少量低频字段,如位置名、纬度和经度;这类字段不得演变成高频观测事实承载体 * `node_system_profiles` 负责存储低频变化的节点事实信息;只有需要列表摘要展示的极少数字段可以回写到 `nodes` * 第六版新增的请求明细、资源快照和聚合统计必须按节点与时间窗口关联 * `node_metric_snapshots` 必须是追加式时间序列快照,不通过覆盖 `nodes` 当前值替代历史 @@ -262,6 +263,7 @@ Agent 必须满足: * 总览页默认展示世界地图节点分布、核心访问指标、趋势图和关键异常,不再以多个对称摘要卡片作为唯一主结构 * 总览页必须能直接展示系统整体运行状况,至少覆盖节点在线率、OpenResty 健康、配置追平状态、容量风险与流量异常 * 总览页异常区优先展示可行动的问题,如离线节点、配置落后节点、资源逼近阈值节点、错误率异常节点 +* 世界地图看板如需真实落点,应优先消费节点上维护的低频位置元数据,不在前端引入不可控的临时地理解析逻辑 * 节点详情页第一屏至少覆盖系统信息、实时资源占用、网络流量与 24 小时历史趋势 * 系统信息卡片除现有 Agent 版本、Nginx 版本、当前配置外,新增操作系统、CPU 型号、在线时长 * 实时资源卡片展示 CPU、内存、存储占用,优先以仪表盘或等效高密度可视化呈现 diff --git a/docs/development-plan.md b/docs/development-plan.md index 78ebd20b..abe23c81 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -23,7 +23,9 @@ * 已完成节点详情观测接口与页面第一轮改造,当前已支持系统画像、实时资源、运行状态、24 小时趋势、状态码分布、Top Domain 与健康事件时间线 * 已完成首页总览专用聚合接口与首页第一轮改造,当前已支持系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表与活动异常 * 已完成首页风险态势到节点页的轻量筛选联动,支持从总览跳转到节点页查看离线节点、OpenResty 异常节点与配置落后节点 -* 当前仍未开始世界地图看板;节点详情的更深层排障交互、首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段 +* 已完成首页总览首屏重构第一版,当前已具备全球态势板、系统健康摘要、请求/容量/网络/磁盘趋势、来源分布、状态码分布与 Top Domain 看板 +* 当前世界板已先接入全球来源信号与节点健康覆盖,节点真实地理点位仍待后续补充地域元数据后进一步完善 +* 节点详情的更深层排障交互、首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段 --- @@ -189,8 +191,8 @@ 当前状态: * 进行中 -* 已完成系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表、活动异常与节点页筛选联动 -* 尚未完成世界地图看板,以及更完整的分布图、来源分布与 Top 榜单扩展 +* 已完成全球态势板第一版、系统健康摘要、峰值摘要、24 小时请求/容量/网络/磁盘趋势、来源分布、状态码分布、Top Domain、Top 节点榜单、处置建议、节点健康列表、活动异常与节点页筛选联动 +* 已完成节点地图元数据链路与首页真实地图落点第一版;尚未完成更完整的世界地图交互,以及更多 WAF 大屏风格的态势增强 任务: