Refactor dashboard overview: remove active alerts and lagging nodes from summary, update tests and components accordingly

- Removed active alerts and lagging nodes from DashboardSummary and related types.
- Updated DashboardOverview component to reflect changes in data structure.
- Adjusted tests to align with the new dashboard overview structure, ensuring no active alerts are displayed.
- Simplified the dashboard metrics and risk signals, focusing on essential health and capacity metrics.
- Enhanced the WorldStage component to present updated traffic and capacity information.
- Removed unused alert-related functions and components to streamline the codebase.
This commit is contained in:
ryan
2026-03-14 16:00:41 +08:00
parent f33e9514dc
commit fe3c6312f9
8 changed files with 118 additions and 900 deletions
-192
View File
@@ -2,11 +2,8 @@ package service
import (
"atsflare/model"
"errors"
"sort"
"time"
"gorm.io/gorm"
)
type DashboardOverviewView struct {
@@ -14,13 +11,9 @@ type DashboardOverviewView struct {
Summary DashboardSummary `json:"summary"`
Traffic DashboardTraffic `json:"traffic"`
Capacity DashboardCapacity `json:"capacity"`
Config DashboardConfig `json:"config"`
Risk DashboardRiskSummary `json:"risk"`
Peaks DashboardPeakSummary `json:"peaks"`
Distributions TrafficDistributions `json:"distributions"`
Trends DashboardTrends `json:"trends"`
Nodes []DashboardNodeHealth `json:"nodes"`
ActiveAlerts []DashboardAlert `json:"active_alerts"`
}
type DashboardSummary struct {
@@ -29,8 +22,6 @@ type DashboardSummary struct {
OfflineNodes int `json:"offline_nodes"`
PendingNodes int `json:"pending_nodes"`
UnhealthyNodes int `json:"unhealthy_nodes"`
ActiveAlerts int `json:"active_alerts"`
LaggingNodes int `json:"lagging_nodes"`
}
type DashboardTraffic struct {
@@ -49,48 +40,6 @@ type DashboardCapacity struct {
HighStorageNodes int `json:"high_storage_nodes"`
}
type DashboardConfig struct {
ActiveVersion string `json:"active_version"`
LaggingNodes int `json:"lagging_nodes"`
PendingNodes int `json:"pending_nodes"`
}
type DashboardRiskSummary struct {
CriticalAlerts int `json:"critical_alerts"`
WarningAlerts int `json:"warning_alerts"`
InfoAlerts int `json:"info_alerts"`
OfflineNodes int `json:"offline_nodes"`
UnhealthyNodes int `json:"unhealthy_nodes"`
LaggingNodes int `json:"lagging_nodes"`
HighCPUNodes int `json:"high_cpu_nodes"`
HighMemoryNodes int `json:"high_memory_nodes"`
HighStorageNodes int `json:"high_storage_nodes"`
}
type DashboardPeakSummary struct {
PeakRequestHour DashboardPeakHour `json:"peak_request_hour"`
PeakErrorHour DashboardPeakHour `json:"peak_error_hour"`
BusiestNode *DashboardPeakNode `json:"busiest_node"`
RiskiestNode *DashboardPeakNode `json:"riskiest_node"`
}
type DashboardPeakHour struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
}
type DashboardPeakNode struct {
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
ActiveEventCount int `json:"active_event_count"`
OpenrestyStatus string `json:"openresty_status"`
StorageUsagePercent float64 `json:"storage_usage_percent"`
}
type DashboardTrends struct {
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
@@ -118,16 +67,6 @@ type DashboardNodeHealth struct {
UniqueVisitorCount int64 `json:"unique_visitor_count"`
}
type DashboardAlert struct {
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
LastTriggeredAt time.Time `json:"last_triggered_at"`
Status string `json:"status"`
}
func GetDashboardOverview() (*DashboardOverviewView, error) {
now := time.Now()
since := now.Add(-24 * time.Hour)
@@ -137,13 +76,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
return nil, err
}
activeVersion := ""
if version, versionErr := model.GetActiveConfigVersion(); versionErr == nil {
activeVersion = version.Version
} else if !errors.Is(versionErr, gorm.ErrRecordNotFound) {
return nil, versionErr
}
snapshots, err := model.ListMetricSnapshotsSince(since)
if err != nil {
return nil, err
@@ -160,7 +92,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view := &DashboardOverviewView{
GeneratedAt: now,
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
ActiveAlerts: make([]DashboardAlert, 0),
Distributions: buildTrafficDistributions(reports, 8),
Trends: DashboardTrends{
Traffic24h: buildTrafficTrendPoints(now, reports),
@@ -183,21 +114,11 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view.Summary.OnlineNodes++
case NodeStatusOffline:
view.Summary.OfflineNodes++
view.Risk.OfflineNodes++
case NodeStatusPending:
view.Summary.PendingNodes++
}
if node.OpenrestyStatus == OpenrestyStatusUnhealthy {
view.Summary.UnhealthyNodes++
view.Risk.UnhealthyNodes++
}
if activeVersion != "" && node.CurrentVersion != "" && node.CurrentVersion != activeVersion {
view.Summary.LaggingNodes++
view.Risk.LaggingNodes++
}
if activeVersion != "" && node.CurrentVersion == "" && computedStatus != NodeStatusPending {
view.Summary.LaggingNodes++
view.Risk.LaggingNodes++
}
latestSnapshot := latestSnapshots[node.NodeID]
@@ -218,26 +139,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
ActiveEventCount: len(nodeActiveEvents),
}
for _, event := range nodeActiveEvents {
view.ActiveAlerts = append(view.ActiveAlerts, DashboardAlert{
NodeID: node.NodeID,
NodeName: node.Name,
EventType: event.EventType,
Severity: event.Severity,
Message: event.Message,
LastTriggeredAt: event.LastTriggeredAt,
Status: event.Status,
})
switch event.Severity {
case NodeHealthSeverityCritical:
view.Risk.CriticalAlerts++
case NodeHealthSeverityWarning:
view.Risk.WarningAlerts++
default:
view.Risk.InfoAlerts++
}
}
if latestSnapshot != nil {
nodeHealth.CPUUsagePercent = latestSnapshot.CPUUsagePercent
nodeHealth.MemoryUsagePercent = percentage(latestSnapshot.MemoryUsedBytes, latestSnapshot.MemoryTotalBytes)
@@ -252,15 +153,12 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
}
if latestSnapshot.CPUUsagePercent >= 80 {
view.Capacity.HighCPUNodes++
view.Risk.HighCPUNodes++
}
if nodeHealth.MemoryUsagePercent >= 85 {
view.Capacity.HighMemoryNodes++
view.Risk.HighMemoryNodes++
}
if nodeHealth.StorageUsagePercent >= 85 {
view.Capacity.HighStorageNodes++
view.Risk.HighStorageNodes++
}
}
@@ -277,14 +175,10 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view.Traffic.ReportedNodes++
}
view.Summary.ActiveAlerts += len(nodeActiveEvents)
view.Nodes = append(view.Nodes, nodeHealth)
}
view.Summary.TotalNodes = len(nodes)
view.Config.ActiveVersion = activeVersion
view.Config.LaggingNodes = view.Summary.LaggingNodes
view.Config.PendingNodes = view.Summary.PendingNodes
if cpuNodeCount > 0 {
view.Capacity.AverageCPUUsagePercent /= float64(cpuNodeCount)
@@ -293,13 +187,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view.Capacity.AverageMemoryUsagePercent /= float64(memoryNodeCount)
}
sort.Slice(view.ActiveAlerts, func(i int, j int) bool {
if severityWeight(view.ActiveAlerts[i].Severity) == severityWeight(view.ActiveAlerts[j].Severity) {
return view.ActiveAlerts[i].LastTriggeredAt.After(view.ActiveAlerts[j].LastTriggeredAt)
}
return severityWeight(view.ActiveAlerts[i].Severity) > severityWeight(view.ActiveAlerts[j].Severity)
})
sort.Slice(view.Nodes, func(i int, j int) bool {
if view.Nodes[i].ActiveEventCount == view.Nodes[j].ActiveEventCount {
return view.Nodes[i].CPUUsagePercent > view.Nodes[j].CPUUsagePercent
@@ -307,19 +194,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
return view.Nodes[i].ActiveEventCount > view.Nodes[j].ActiveEventCount
})
if len(view.ActiveAlerts) > 8 {
view.ActiveAlerts = view.ActiveAlerts[:8]
}
view.Peaks.PeakRequestHour = peakTrafficHour(view.Trends.Traffic24h, func(point TrafficTrendPoint) int64 {
return point.RequestCount
})
view.Peaks.PeakErrorHour = peakTrafficHour(view.Trends.Traffic24h, func(point TrafficTrendPoint) int64 {
return point.ErrorCount
})
view.Peaks.BusiestNode = busiestDashboardNode(view.Nodes)
view.Peaks.RiskiestNode = riskiestDashboardNode(view.Nodes)
return view, nil
}
@@ -368,69 +242,3 @@ func activeHealthEventsByNode(events []*model.NodeHealthEvent) map[string][]*mod
}
return result
}
func severityWeight(severity string) int {
switch severity {
case NodeHealthSeverityCritical:
return 3
case NodeHealthSeverityWarning:
return 2
default:
return 1
}
}
func peakTrafficHour(points []TrafficTrendPoint, selector func(point TrafficTrendPoint) int64) DashboardPeakHour {
var result DashboardPeakHour
var maxValue int64 = -1
for _, point := range points {
value := selector(point)
if value <= maxValue {
continue
}
maxValue = value
result = DashboardPeakHour{
BucketStartedAt: point.BucketStartedAt,
RequestCount: point.RequestCount,
ErrorCount: point.ErrorCount,
}
}
return result
}
func busiestDashboardNode(nodes []DashboardNodeHealth) *DashboardPeakNode {
var selected *DashboardPeakNode
for _, node := range nodes {
candidate := &DashboardPeakNode{
NodeID: node.NodeID,
NodeName: node.Name,
RequestCount: node.RequestCount,
ErrorCount: node.ErrorCount,
CPUUsagePercent: node.CPUUsagePercent,
ActiveEventCount: node.ActiveEventCount,
OpenrestyStatus: node.OpenrestyStatus,
StorageUsagePercent: node.StorageUsagePercent,
}
if selected == nil || candidate.RequestCount > selected.RequestCount || (candidate.RequestCount == selected.RequestCount && candidate.ErrorCount > selected.ErrorCount) {
selected = candidate
}
}
return selected
}
func riskiestDashboardNode(nodes []DashboardNodeHealth) *DashboardPeakNode {
if len(nodes) == 0 {
return nil
}
node := nodes[0]
return &DashboardPeakNode{
NodeID: node.NodeID,
NodeName: node.Name,
RequestCount: node.RequestCount,
ErrorCount: node.ErrorCount,
CPUUsagePercent: node.CPUUsagePercent,
ActiveEventCount: node.ActiveEventCount,
OpenrestyStatus: node.OpenrestyStatus,
StorageUsagePercent: node.StorageUsagePercent,
}
}
+14 -20
View File
@@ -1005,24 +1005,24 @@ func TestGetDashboardOverview(t *testing.T) {
if view.Summary.TotalNodes != 2 || view.Summary.OnlineNodes != 2 {
t.Fatalf("unexpected dashboard summary: %+v", view.Summary)
}
if view.Summary.UnhealthyNodes != 1 || view.Summary.ActiveAlerts != 1 {
t.Fatalf("unexpected unhealthy/alert summary: %+v", view.Summary)
if view.Summary.UnhealthyNodes != 1 {
t.Fatalf("unexpected unhealthy summary: %+v", view.Summary)
}
if view.Traffic.RequestCount != 900 || view.Traffic.ErrorCount != 36 {
t.Fatalf("unexpected dashboard traffic: %+v", view.Traffic)
}
if view.Config.ActiveVersion != "20260314-001" || view.Config.LaggingNodes != 1 {
t.Fatalf("unexpected dashboard config summary: %+v", view.Config)
if view.Capacity.HighCPUNodes != 1 || view.Capacity.HighMemoryNodes != 1 {
t.Fatalf("unexpected dashboard capacity summary: %+v", view.Capacity)
}
if view.Risk.CriticalAlerts != 1 || view.Risk.HighCPUNodes != 1 || view.Risk.HighMemoryNodes != 1 {
t.Fatalf("unexpected dashboard risk summary: %+v", view.Risk)
}
if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts)
if len(view.Nodes) != 2 {
t.Fatalf("unexpected dashboard nodes: %+v", view.Nodes)
}
if view.Nodes[0].GeoName == "" && view.Nodes[1].GeoName == "" {
t.Fatalf("expected dashboard nodes to expose geo metadata: %+v", view.Nodes)
}
if view.Nodes[0].ActiveEventCount != 1 {
t.Fatalf("expected dashboard nodes to preserve active event counts: %+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)
}
@@ -1044,25 +1044,19 @@ func TestGetDashboardOverview(t *testing.T) {
if len(view.Distributions.TopDomains) == 0 || view.Distributions.TopDomains[0].Key != "app.example.com" {
t.Fatalf("unexpected dashboard domain distributions: %+v", view.Distributions.TopDomains)
}
if view.Peaks.BusiestNode == nil || view.Peaks.BusiestNode.NodeID != "node-dashboard-a" {
t.Fatalf("unexpected busiest node: %+v", view.Peaks.BusiestNode)
}
if view.Peaks.RiskiestNode == nil || view.Peaks.RiskiestNode.NodeID != "node-dashboard-b" {
t.Fatalf("unexpected riskiest node: %+v", view.Peaks.RiskiestNode)
}
}
func TestGetDashboardOverviewReturnsEmptyAlertSlice(t *testing.T) {
func TestGetDashboardOverviewReturnsEmptyNodeSlice(t *testing.T) {
setupServiceTestDB(t)
view, err := GetDashboardOverview()
if err != nil {
t.Fatalf("GetDashboardOverview failed: %v", err)
}
if view.ActiveAlerts == nil {
t.Fatalf("expected active alerts to be an empty slice, got nil")
if view.Nodes == nil {
t.Fatalf("expected nodes to be an empty slice, got nil")
}
if len(view.ActiveAlerts) != 0 {
t.Fatalf("expected empty active alerts, got %+v", view.ActiveAlerts)
if len(view.Nodes) != 0 {
t.Fatalf("expected empty nodes, got %+v", view.Nodes)
}
}
@@ -16,7 +16,6 @@ function normalizeDashboardOverview(
return {
...overview,
nodes: arrayOrEmpty(overview.nodes),
active_alerts: arrayOrEmpty(overview.active_alerts),
distributions: {
...overview.distributions,
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
@@ -11,11 +11,7 @@ import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getDashboardOverview } from '@/features/dashboard/api/overview';
import type {
DashboardAlert,
DashboardNodeHealth,
DashboardPeakNode,
} from '@/features/dashboard/types';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import { WorldStage } from '@/features/dashboard/components/world-stage';
import {
getNodeStatusLabel,
@@ -59,48 +55,6 @@ 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' {
if (alert.status === 'resolved') {
return 'success';
}
if (alert.severity === 'critical') {
return 'danger';
}
if (alert.severity === 'warning') {
return 'warning';
}
return 'info';
}
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';
@@ -173,82 +127,6 @@ function OverviewMetric({
);
}
function RiskSignal({
label,
value,
tone,
hint,
href,
}: {
label: string;
value: number;
tone: 'danger' | 'warning' | 'info' | 'success';
hint: string;
href?: string;
}) {
const toneClass =
tone === 'danger'
? 'border-rose-400/28 bg-rose-500/10 text-rose-100'
: tone === 'warning'
? 'border-amber-400/28 bg-amber-500/10 text-amber-100'
: tone === 'success'
? '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] 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}
</Link>
);
}
function PeakCard({
label,
value,
hint,
href,
}: {
label: string;
value: string;
hint: string;
href?: string;
}) {
const content = (
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
if (!href) {
return content;
}
return (
<Link
href={href}
className="block transition hover:[&_div]:border-[var(--border-strong)] hover:[&_div]:bg-[var(--surface-muted)]"
>
{content}
</Link>
);
}
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
return (
<Link
@@ -354,148 +232,60 @@ export function DashboardOverview() {
);
}
const nodeIdMap = new Map(
overview.nodes.map((node) => [node.node_id, node.id] as const),
);
return (
<div className="space-y-6">
<WorldStage
generatedAt={overview.generated_at}
summary={overview.summary}
risk={overview.risk}
config={overview.config}
traffic={overview.traffic}
capacity={overview.capacity}
nodes={overview.nodes}
sourceCountries={overview.distributions.source_countries}
/>
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<AppCard
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}
tone="danger"
hint="当前仍在触发中的严重异常"
/>
<RiskSignal
label="Warning"
value={overview.risk.warning_alerts}
tone="warning"
hint="需要尽快介入但尚未到故障级"
/>
<RiskSignal
label="配置落后"
value={overview.risk.lagging_nodes}
tone="info"
hint="当前版本未追平全局激活配置"
href="/node?risk=lagging"
/>
<RiskSignal
label="OpenResty 异常"
value={overview.risk.unhealthy_nodes}
tone="danger"
hint="运行态已出现不健康节点"
href="/node?risk=unhealthy"
/>
<RiskSignal
label="高 CPU / 内存"
value={
overview.risk.high_cpu_nodes + overview.risk.high_memory_nodes
}
tone="warning"
hint={`${overview.risk.high_cpu_nodes} 个高 CPU · ${overview.risk.high_memory_nodes} 个高内存`}
/>
<RiskSignal
label="离线节点"
value={overview.risk.offline_nodes}
tone="warning"
hint="节点长时间未心跳或已失联"
href="/node?risk=offline"
/>
</div>
</AppCard>
</div>
<AppCard
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="OpenResty 异常"
value={overview.summary.unhealthy_nodes}
hint="当前运行态已出现不健康节点"
accent="rose"
/>
<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"
/>
<OverviewMetric
label="高存储节点"
value={overview.capacity.high_storage_nodes}
hint={`${overview.traffic.reported_nodes} 个节点参与当前窗口上报`}
accent="amber"
/>
</div>
</AppCard>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
@@ -559,7 +349,7 @@ export function DashboardOverview() {
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="24 小时网络与磁盘趋势"
description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。"
@@ -621,94 +411,9 @@ export function DashboardOverview() {
</div>
</AppCard>
<AppCard
title="活动异常"
description="优先展示当前仍在触发中的问题。"
>
{overview.active_alerts.length === 0 ? (
<EmptyState
title="暂无活动异常"
description="当前系统没有正在触发的节点健康事件。"
/>
) : (
<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="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="把流量峰值、错误热点和容量压力节点并排展示,方便值守时快速判断先处理谁。"
description="把流量、错误与容量压力节点并排展示,保留快速定位热点与瓶颈的入口。"
>
<div className="grid gap-6 xl:grid-cols-3">
<div>
@@ -760,61 +465,49 @@ export function DashboardOverview() {
</div>
</div>
</AppCard>
</div>
<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="把首页信号翻译成可行动的下一步,降低值守判断成本。"
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>
<RankChart
items={overview.distributions.status_codes.map((item) => ({
label: `HTTP ${item.key}`,
value: item.value,
}))}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</AppCard>
<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
title="Top Domain"
description="观察主要流量集中在哪些域名,方便判断业务热度与异常集中点。"
>
<RankChart
items={overview.distributions.top_domains.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#34d399"
emptyMessage="暂无域名分布"
/>
</AppCard>
</div>
@@ -285,8 +285,8 @@ export function WorldStageMap({
roam: false,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: '132%',
zoom: 1.12,
layoutSize: '220%',
zoom: 1.2,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
@@ -1,25 +1,17 @@
'use client';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
DashboardConfig,
DashboardCapacity,
DashboardNodeHealth,
DashboardRiskSummary,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
} from '@/features/nodes/utils';
import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
@@ -40,13 +32,6 @@ function formatPercent(value: number) {
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
}
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function HeroMetric({
label,
value,
@@ -174,15 +159,15 @@ function CountrySignal({
export function WorldStage({
generatedAt,
summary,
risk,
config,
traffic,
capacity,
nodes,
sourceCountries,
}: {
generatedAt: string;
summary: DashboardSummary;
risk: DashboardRiskSummary;
config: DashboardConfig;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
@@ -223,15 +208,9 @@ export function WorldStage({
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,
summary.online_nodes - summary.unhealthy_nodes,
);
const healthyRate =
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
@@ -280,7 +259,7 @@ export function WorldStage({
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源,
在同一视图汇总节点在线率、运行状态、资源负载与全球流量来源,
让首屏可以直接承担值守与研判入口。
</p>
</div>
@@ -396,7 +375,7 @@ export function WorldStage({
</div>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
<HeroMetric
label="在线覆盖"
value={formatPercent(onlineRate)}
@@ -410,119 +389,18 @@ export function WorldStage({
isDark={isDark}
/>
<HeroMetric
label="配置追平"
value={formatPercent(syncRate)}
hint={`${summary.lagging_nodes} 个节点未对齐 ${config.active_version || '当前激活版本'}`}
label="最近窗口请求"
value={traffic.request_count.toLocaleString('zh-CN')}
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
isDark={isDark}
/>
<HeroMetric
label="活动风险"
value={summary.active_alerts.toLocaleString('zh-CN')}
hint={`${risk.critical_alerts} 个严重告警 · ${risk.warning_alerts} 个预警`}
label="平均 CPU"
value={formatPercent(capacity.average_cpu_usage_percent)}
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
isDark={isDark}
/>
</div>
<div
className={cn(
'rounded-[28px] border px-5 py-5 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-slate-200/80 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p
className={cn(
'text-[11px] tracking-[0.24em] uppercase',
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
节点风险清单
</p>
<p
className={cn(
'mt-2 text-lg font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
优先处置节点
</p>
</div>
<Link
href="/node"
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
isDark
? 'border-white/12 text-slate-100 hover:bg-white/8'
: 'border-slate-200 text-slate-700 hover:bg-slate-100/80',
)}
>
查看全部节点
</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={cn(
'block rounded-2xl border px-4 py-4 transition',
isDark
? 'border-white/8 bg-slate-950/20 hover:border-white/18 hover:bg-white/6'
: 'border-slate-200/80 bg-slate-50/70 hover:border-slate-300 hover:bg-white',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p
className={cn(
'text-sm font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{node.name}
</p>
<p
className={cn(
'mt-1 text-xs',
isDark ? 'text-slate-400' : 'text-slate-500',
)}
>
请求量 {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={cn(
'rounded-2xl border border-dashed px-4 py-5 text-sm',
isDark
? 'border-white/12 bg-slate-950/20 text-slate-300'
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
)}
>
当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
</div>
) : null}
</div>
</div>
</div>
</div>
</section>
@@ -4,8 +4,6 @@ export interface DashboardSummary {
offline_nodes: number;
pending_nodes: number;
unhealthy_nodes: number;
active_alerts: number;
lagging_nodes: number;
}
export interface DashboardTraffic {
@@ -24,48 +22,6 @@ export interface DashboardCapacity {
high_storage_nodes: number;
}
export interface DashboardConfig {
active_version: string;
lagging_nodes: number;
pending_nodes: number;
}
export interface DashboardRiskSummary {
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
offline_nodes: number;
unhealthy_nodes: number;
lagging_nodes: number;
high_cpu_nodes: number;
high_memory_nodes: number;
high_storage_nodes: number;
}
export interface DashboardPeakHour {
bucket_started_at: string;
request_count: number;
error_count: number;
}
export interface DashboardPeakNode {
node_id: string;
node_name: string;
request_count: number;
error_count: number;
cpu_usage_percent: number;
active_event_count: number;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
storage_usage_percent: number;
}
export interface DashboardPeakSummary {
peak_request_hour: DashboardPeakHour;
peak_error_hour: DashboardPeakHour;
busiest_node: DashboardPeakNode | null;
riskiest_node: DashboardPeakNode | null;
}
export interface DistributionItem {
key: string;
value: number;
@@ -134,26 +90,12 @@ export interface DashboardNodeHealth {
unique_visitor_count: number;
}
export interface DashboardAlert {
node_id: string;
node_name: string;
event_type: string;
severity: 'info' | 'warning' | 'critical';
message: string;
last_triggered_at: string;
status: 'active' | 'resolved';
}
export interface DashboardOverview {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
config: DashboardConfig;
risk: DashboardRiskSummary;
peaks: DashboardPeakSummary;
distributions: TrafficDistributions;
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
active_alerts: DashboardAlert[];
}
@@ -71,8 +71,6 @@ describe('DashboardOverview', () => {
offline_nodes: 0,
pending_nodes: 0,
unhealthy_nodes: 1,
active_alerts: 1,
lagging_nodes: 1,
},
traffic: {
request_count: 900,
@@ -88,54 +86,6 @@ describe('DashboardOverview', () => {
high_memory_nodes: 1,
high_storage_nodes: 1,
},
config: {
active_version: '20260314-001',
lagging_nodes: 1,
pending_nodes: 0,
},
risk: {
critical_alerts: 1,
warning_alerts: 2,
info_alerts: 0,
offline_nodes: 0,
unhealthy_nodes: 1,
lagging_nodes: 1,
high_cpu_nodes: 1,
high_memory_nodes: 1,
high_storage_nodes: 1,
},
peaks: {
peak_request_hour: {
bucket_started_at: '2026-03-14T08:00:00Z',
request_count: 900,
error_count: 36,
},
peak_error_hour: {
bucket_started_at: '2026-03-14T09:00:00Z',
request_count: 400,
error_count: 60,
},
busiest_node: {
node_id: 'node-a',
node_name: 'edge-a',
request_count: 600,
error_count: 6,
cpu_usage_percent: 45,
active_event_count: 0,
openresty_status: 'healthy',
storage_usage_percent: 60,
},
riskiest_node: {
node_id: 'node-b',
node_name: 'edge-b',
request_count: 300,
error_count: 30,
cpu_usage_percent: 92,
active_event_count: 2,
openresty_status: 'unhealthy',
storage_usage_percent: 95,
},
},
distributions: {
source_countries: [
{ key: 'CN', value: 440 },
@@ -220,17 +170,6 @@ describe('DashboardOverview', () => {
unique_visitor_count: 80,
},
],
active_alerts: [
{
node_id: 'node-b',
node_name: 'edge-b',
event_type: 'openresty_unhealthy',
severity: 'critical',
message: 'reload failed',
last_triggered_at: '2026-03-14T07:59:00Z',
status: 'active',
},
],
},
}),
),
@@ -249,7 +188,6 @@ describe('DashboardOverview', () => {
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();
});
@@ -275,8 +213,6 @@ describe('DashboardOverview', () => {
offline_nodes: 0,
pending_nodes: 0,
unhealthy_nodes: 0,
active_alerts: 0,
lagging_nodes: 0,
},
traffic: {
request_count: 0,
@@ -292,36 +228,6 @@ describe('DashboardOverview', () => {
high_memory_nodes: 0,
high_storage_nodes: 0,
},
config: {
active_version: '',
lagging_nodes: 0,
pending_nodes: 0,
},
risk: {
critical_alerts: 0,
warning_alerts: 0,
info_alerts: 0,
offline_nodes: 0,
unhealthy_nodes: 0,
lagging_nodes: 0,
high_cpu_nodes: 0,
high_memory_nodes: 0,
high_storage_nodes: 0,
},
peaks: {
peak_request_hour: {
bucket_started_at: '',
request_count: 0,
error_count: 0,
},
peak_error_hour: {
bucket_started_at: '',
request_count: 0,
error_count: 0,
},
busiest_node: null,
riskiest_node: null,
},
distributions: {
source_countries: null,
status_codes: null,
@@ -334,7 +240,6 @@ describe('DashboardOverview', () => {
disk_io_24h: null,
},
nodes: null,
active_alerts: null,
},
}),
),
@@ -347,7 +252,6 @@ describe('DashboardOverview', () => {
renderDashboardOverview();
expect(await screen.findByText('暂无活动异常')).toBeInTheDocument();
expect(await screen.findByText('暂无节点')).toBeInTheDocument();
expect(await screen.findByText('暂无来源分布数据')).toBeInTheDocument();
});