mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
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.
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`rounded-3xl border border-[var(--border-default)] bg-[linear-gradient(180deg,var(--surface-elevated),var(--surface-card))] px-5 py-5 shadow-[var(--shadow-soft)]`}
|
||||
>
|
||||
<div className={`rounded-2xl bg-gradient-to-r ${accentClass} p-0.5`}>
|
||||
<div className="rounded-[15px] bg-transparent">
|
||||
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className={`rounded-3xl border px-4 py-4 ${toneClass}`}>
|
||||
<p className="text-xs tracking-[0.22em] uppercase opacity-75">{label}</p>
|
||||
<p className="text-xs tracking-[0.22em] opacity-75 uppercase">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{value}</p>
|
||||
<p className="mt-2 text-sm opacity-80">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!href) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className="block transition hover:opacity-90">
|
||||
{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 (
|
||||
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 py-5">
|
||||
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
return (
|
||||
<Link
|
||||
@@ -240,7 +298,7 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
最近窗口请求
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.request_count}
|
||||
{node.request_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -302,50 +360,98 @@ export function DashboardOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="系统运行总览"
|
||||
description="优先回答当前系统是否健康、容量是否紧张、是否存在配置落后和异常节点。"
|
||||
action={
|
||||
<div className="text-right text-sm text-[var(--foreground-secondary)]">
|
||||
生成时间:{formatDateTime(overview.generated_at)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
<OverviewMetric
|
||||
label="在线节点"
|
||||
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
|
||||
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="活动异常"
|
||||
value={overview.summary.active_alerts}
|
||||
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="配置落后"
|
||||
value={overview.summary.lagging_nodes}
|
||||
hint={overview.config.active_version || '当前无激活版本'}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="最近窗口请求"
|
||||
value={overview.traffic.request_count}
|
||||
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors}`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
|
||||
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
<WorldStage
|
||||
generatedAt={overview.generated_at}
|
||||
summary={overview.summary}
|
||||
risk={overview.risk}
|
||||
config={overview.config}
|
||||
nodes={overview.nodes}
|
||||
sourceCountries={overview.distributions.source_countries}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AppCard
|
||||
title="风险态势"
|
||||
description="把异常、容量压力和配置落后聚到同一层,方便先确定今天该盯哪里。"
|
||||
title="系统健康摘要"
|
||||
description="先回答系统是否健康、容量是否紧张、是否存在追平偏差和异常流量。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<OverviewMetric
|
||||
label="在线节点"
|
||||
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
|
||||
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
|
||||
accent="sky"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="活动异常"
|
||||
value={overview.summary.active_alerts}
|
||||
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
accent="rose"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="配置落后"
|
||||
value={overview.summary.lagging_nodes}
|
||||
hint={overview.config.active_version || '当前无激活版本'}
|
||||
accent="amber"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="最近窗口请求"
|
||||
value={overview.traffic.request_count.toLocaleString('zh-CN')}
|
||||
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors.toLocaleString('zh-CN')}`}
|
||||
accent="emerald"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
|
||||
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
|
||||
accent="amber"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(overview.capacity.average_memory_usage_percent)}
|
||||
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
|
||||
accent="sky"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="峰值与风险摘要"
|
||||
description="快速回答什么时候最忙、哪里最危险,以及首页能直接行动的入口。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PeakCard
|
||||
label="请求峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count.toLocaleString('zh-CN')}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="错误峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_error_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count.toLocaleString('zh-CN')}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="最忙节点"
|
||||
value={formatPeakNode(overview.peaks.busiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.busiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
<PeakCard
|
||||
label="优先排查节点"
|
||||
value={formatPeakNode(overview.peaks.riskiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<RiskSignal
|
||||
label="Critical"
|
||||
value={overview.risk.critical_alerts}
|
||||
@@ -380,12 +486,6 @@ export function DashboardOverview() {
|
||||
tone="warning"
|
||||
hint={`${overview.risk.high_cpu_nodes} 个高 CPU · ${overview.risk.high_memory_nodes} 个高内存`}
|
||||
/>
|
||||
<RiskSignal
|
||||
label="高存储压力"
|
||||
value={overview.risk.high_storage_nodes}
|
||||
tone="warning"
|
||||
hint={`${overview.risk.offline_nodes} 个离线节点`}
|
||||
/>
|
||||
<RiskSignal
|
||||
label="离线节点"
|
||||
value={overview.risk.offline_nodes}
|
||||
@@ -395,44 +495,6 @@ export function DashboardOverview() {
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="峰值摘要"
|
||||
description="快速回答什么时候最忙、什么时候最危险,以及该先看哪台节点。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PeakCard
|
||||
label="请求峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="错误峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_error_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="最忙节点"
|
||||
value={formatPeakNode(overview.peaks.busiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.busiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
<PeakCard
|
||||
label="优先排查节点"
|
||||
value={formatPeakNode(overview.peaks.riskiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
@@ -497,99 +559,290 @@ export function DashboardOverview() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
title="24 小时网络与磁盘趋势"
|
||||
description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。"
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<div className="space-y-6">
|
||||
<TrendChart
|
||||
labels={overview.trends.network_24h.map((point) =>
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
labels={overview.trends.disk_io_24h.map((point) =>
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="活动异常"
|
||||
description="优先展示当前仍在触发中的问题。"
|
||||
>
|
||||
{overview.active_alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
title="暂无活动异常"
|
||||
description="当前系统没有正在触发的节点健康事件。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
<div className="space-y-3">
|
||||
{overview.active_alerts.map((alert) => (
|
||||
<Link
|
||||
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
|
||||
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={alert.event_type.replaceAll('_', ' ')}
|
||||
variant={getAlertVariant(alert)}
|
||||
/>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{alert.node_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{alert.message || '暂无详细消息'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
|
||||
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
|
||||
{formatDateTime(alert.last_triggered_at)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="流量与容量"
|
||||
description="聚合最近窗口流量和资源压力,作为首页一级信号。"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<OverviewMetric
|
||||
label="最近窗口错误"
|
||||
value={overview.traffic.error_count}
|
||||
hint={`${overview.traffic.reported_nodes} 个节点上报`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(
|
||||
overview.capacity.average_memory_usage_percent,
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard
|
||||
title="来源分布"
|
||||
description="聚合最近 24 小时主要来源国家,优先识别流量重心变化。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.source_countries.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无来源分布数据"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="状态码分布"
|
||||
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.status_codes.map((item) => ({
|
||||
label: `HTTP ${item.key}`,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Top Domain"
|
||||
description="观察主要流量集中在哪些域名,方便判断业务热度与异常集中点。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.top_domains.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#34d399"
|
||||
emptyMessage="暂无域名分布"
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<AppCard
|
||||
title="Top 节点榜单"
|
||||
description="把流量峰值、错误热点和容量压力节点并排展示,方便值守时快速判断先处理谁。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
流量最高节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.request_count,
|
||||
)}
|
||||
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="高存储压力"
|
||||
value={overview.capacity.high_storage_nodes}
|
||||
hint="存储使用率 >= 85%"
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无流量榜单"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="活动异常"
|
||||
description="优先展示当前仍在触发中的问题。"
|
||||
>
|
||||
{overview.active_alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无活动异常"
|
||||
description="当前系统没有正在触发的节点健康事件。"
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
错误热点节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.error_count,
|
||||
)}
|
||||
color="#f97316"
|
||||
emptyMessage="暂无错误热点"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{overview.active_alerts.map((alert) => (
|
||||
<Link
|
||||
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
|
||||
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={alert.event_type.replaceAll('_', ' ')}
|
||||
variant={getAlertVariant(alert)}
|
||||
/>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{alert.node_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{alert.message || '暂无详细消息'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
|
||||
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
|
||||
{formatDateTime(alert.last_triggered_at)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
容量压力节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) =>
|
||||
Math.round(
|
||||
Math.max(
|
||||
node.cpu_usage_percent,
|
||||
node.memory_usage_percent,
|
||||
node.storage_usage_percent,
|
||||
),
|
||||
),
|
||||
)}
|
||||
color="#ef4444"
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage="暂无容量压力数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="处置建议"
|
||||
description="把首页信号翻译成可行动的下一步,降低值守判断成本。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-rose-200 uppercase">
|
||||
第一优先级
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
先处理活动异常与 OpenResty 不健康节点
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
当前有 {overview.summary.active_alerts} 个活动异常,{` `}
|
||||
{overview.summary.unhealthy_nodes} 个 OpenResty 不健康节点。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-amber-400/18 bg-amber-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-amber-200 uppercase">
|
||||
第二优先级
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
检查配置落后与容量逼近节点
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
当前有 {overview.summary.lagging_nodes} 个配置落后节点,{` `}
|
||||
{overview.capacity.high_cpu_nodes +
|
||||
overview.capacity.high_memory_nodes +
|
||||
overview.capacity.high_storage_nodes}{' '}
|
||||
个节点出现明显容量压力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-sky-400/18 bg-sky-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-sky-200 uppercase">
|
||||
观察项
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
关注峰值时段与来源分布变化
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
请求峰值出现在 {formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
,当前主要来源集中在
|
||||
{overview.distributions.source_countries
|
||||
.slice(0, 2)
|
||||
.map((item) => item.key)
|
||||
.join(' / ') || ' 暂无来源数据'}
|
||||
。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/6 px-4 py-4 backdrop-blur">
|
||||
<p className="text-[11px] tracking-[0.26em] text-slate-300 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-white">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`rounded-2xl border border-white/10 bg-gradient-to-br px-4 py-3 backdrop-blur ${accents[index % accents.length]}`}
|
||||
>
|
||||
<p className="text-[11px] tracking-[0.24em] text-slate-200 uppercase">
|
||||
{item.key}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">
|
||||
{item.value.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-300">最近 24 小时来源信号</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="overflow-hidden rounded-[32px] border border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]">
|
||||
<div className="border-b border-white/8 px-6 py-5 md:px-7">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] tracking-[0.34em] text-sky-200/80 uppercase">
|
||||
Global Stage
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-white">全球态势板</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-slate-300">
|
||||
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
|
||||
让总览页真正承担值守入口的职责。
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-4 py-2 text-sm text-slate-200 backdrop-blur">
|
||||
数据生成于 {formatDateTime(generatedAt)}
|
||||
</div>
|
||||
</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="relative min-h-[360px] overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.08)_1px,transparent_1px)] bg-[size:40px_40px] opacity-35" />
|
||||
<div className="absolute left-8 top-8 rounded-full bg-sky-400/20 px-3 py-1 text-[11px] tracking-[0.22em] text-sky-100 uppercase backdrop-blur">
|
||||
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 1000 420"
|
||||
className="absolute inset-0 h-full w-full opacity-80"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="1.5">
|
||||
<path d="M86 98C118 84 168 80 206 92C244 104 270 132 262 154C254 174 210 182 178 180C146 178 108 164 94 142C80 120 76 106 86 98Z" />
|
||||
<path d="M246 210C270 220 286 248 282 276C278 304 266 332 252 360C236 352 228 328 220 306C212 282 210 252 220 232C226 220 236 212 246 210Z" />
|
||||
<path d="M432 86C456 72 510 70 538 84C566 98 566 122 544 132C524 140 488 138 460 130C436 122 418 100 432 86Z" />
|
||||
<path d="M494 154C524 150 552 164 568 188C586 214 592 246 582 274C572 302 546 326 518 326C494 326 474 302 468 276C460 244 458 212 466 186C472 168 480 158 494 154Z" />
|
||||
<path d="M574 84C612 64 674 64 724 78C774 92 832 116 860 152C886 186 880 226 846 244C814 262 752 256 704 248C650 238 598 218 574 186C554 158 548 100 574 84Z" />
|
||||
<path d="M804 298C824 288 858 290 882 302C904 312 912 332 900 346C888 360 856 364 830 360C806 356 788 338 790 320C792 310 796 302 804 298Z" />
|
||||
</g>
|
||||
<g stroke="rgba(56,189,248,0.22)" strokeDasharray="6 8">
|
||||
<path d="M160 150C274 108 414 102 516 146C652 206 742 170 854 182" />
|
||||
<path d="M248 286C358 226 430 206 544 208C668 210 760 256 858 322" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{signalNodes.map((node, index) => {
|
||||
const anchor = projectNodeCoordinates(node, index);
|
||||
const tone = getNodeSignalTone(node);
|
||||
return (
|
||||
<Link
|
||||
key={node.node_id}
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className={`absolute w-[184px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border px-4 py-3 backdrop-blur transition hover:scale-[1.02] ${tone.ring}`}
|
||||
style={anchor}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<span
|
||||
className={`absolute inset-0 rounded-full blur-md ${tone.halo}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative block h-3.5 w-3.5 rounded-full ${tone.dot}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-300">
|
||||
{(node.geo_name || node.name) + ' · '}
|
||||
请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '}
|
||||
{node.active_event_count}
|
||||
</p>
|
||||
{!anchor.derivedFromGeo ? (
|
||||
<p className="mt-1 text-[11px] text-slate-400">
|
||||
未配置经纬度,当前使用备用落点
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 grid gap-3 md:grid-cols-3">
|
||||
{sourceCountries.length > 0 ? (
|
||||
sourceCountries.slice(0, 3).map((item, index) => (
|
||||
<CountrySignal
|
||||
key={`${item.key}-${item.value}`}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/15 bg-white/5 px-4 py-4 text-sm text-slate-300 md:col-span-3">
|
||||
当前还没有可用于全球分布展示的来源国家数据。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 px-4 py-3 text-xs leading-6 text-slate-300 backdrop-blur">
|
||||
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
|
||||
未配置坐标的节点仍会使用备用落点,不影响首页态势判断。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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} 节点在线`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="运行健康"
|
||||
value={formatPercent(healthyRate)}
|
||||
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="配置追平"
|
||||
value={formatPercent(syncRate)}
|
||||
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="活动风险"
|
||||
value={summary.active_alerts.toLocaleString('zh-CN')}
|
||||
hint={`${risk.critical_alerts} Critical · ${risk.warning_alerts} Warning`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/6 px-5 py-5 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] tracking-[0.24em] text-slate-300 uppercase">
|
||||
节点健康清单
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">
|
||||
风险优先队列
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/node"
|
||||
className="rounded-full border border-white/12 px-3 py-1.5 text-xs text-slate-100 transition hover:bg-white/8"
|
||||
>
|
||||
查看全部节点
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nodes.slice(0, 4).map((node) => (
|
||||
<Link
|
||||
key={node.node_id}
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className="block rounded-2xl border border-white/8 bg-slate-950/20 px-4 py-4 transition hover:border-white/18 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
|
||||
{node.error_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{nodes.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-slate-950/20 px-4 py-5 text-sm text-slate-300">
|
||||
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现世界覆盖信号。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof nodeSchema>;
|
||||
@@ -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() {
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
@@ -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() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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` 承担最近心跳窗口内的请求批次或受控明细,不作为长期日志平台
|
||||
|
||||
@@ -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、内存、存储占用,优先以仪表盘或等效高密度可视化呈现
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
* 已完成节点详情观测接口与页面第一轮改造,当前已支持系统画像、实时资源、运行状态、24 小时趋势、状态码分布、Top Domain 与健康事件时间线
|
||||
* 已完成首页总览专用聚合接口与首页第一轮改造,当前已支持系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表与活动异常
|
||||
* 已完成首页风险态势到节点页的轻量筛选联动,支持从总览跳转到节点页查看离线节点、OpenResty 异常节点与配置落后节点
|
||||
* 当前仍未开始世界地图看板;节点详情的更深层排障交互、首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段
|
||||
* 已完成首页总览首屏重构第一版,当前已具备全球态势板、系统健康摘要、请求/容量/网络/磁盘趋势、来源分布、状态码分布与 Top Domain 看板
|
||||
* 当前世界板已先接入全球来源信号与节点健康覆盖,节点真实地理点位仍待后续补充地域元数据后进一步完善
|
||||
* 节点详情的更深层排障交互、首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段
|
||||
|
||||
---
|
||||
|
||||
@@ -189,8 +191,8 @@
|
||||
当前状态:
|
||||
|
||||
* 进行中
|
||||
* 已完成系统运行总览、风险态势、峰值摘要、24 小时趋势、节点健康列表、活动异常与节点页筛选联动
|
||||
* 尚未完成世界地图看板,以及更完整的分布图、来源分布与 Top 榜单扩展
|
||||
* 已完成全球态势板第一版、系统健康摘要、峰值摘要、24 小时请求/容量/网络/磁盘趋势、来源分布、状态码分布、Top Domain、Top 节点榜单、处置建议、节点健康列表、活动异常与节点页筛选联动
|
||||
* 已完成节点地图元数据链路与首页真实地图落点第一版;尚未完成更完整的世界地图交互,以及更多 WAF 大屏风格的态势增强
|
||||
|
||||
任务:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user