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 大屏风格的态势增强
任务:
|