From 497289b46201c0c99654cdaae5aebcc49a05cc32 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 14 Mar 2026 12:12:38 +0800 Subject: [PATCH] feat(dashboard): enhance risk summary and peak node display in dashboard overview - Added risk summary metrics including critical alerts, high CPU/memory nodes, and lagging nodes to the dashboard overview. - Introduced peak node metrics for busiest and riskiest nodes with detailed display. - Updated types for dashboard overview to include risk and peak summaries. - Implemented new components for displaying risk signals and peak cards. - Enhanced unit tests to cover new risk and peak metrics in the dashboard overview. fix(update): improve error messages for server upgrade processes - Translated and clarified error messages related to server upgrades and binary uploads. - Ensured consistent error handling and messaging throughout the upgrade process. test(update): refactor test utilities for server upgrade - Renamed roundTripFunc to serverUpdateRoundTripFunc for clarity in test code. - Updated test cases to utilize the new naming convention. feat(node-detail): add traffic structure distribution and health event timeline - Implemented traffic breakdown visualization for status codes and top domains in node detail page. - Added health event timeline to display active and resolved events for better observability. feat(rank-chart): create reusable rank chart component - Developed a new RankChart component for visualizing ranked data distributions. - Integrated RankChart into node detail page for displaying status code and domain distributions. --- atsf_server/service/dashboard.go | 117 +++++++++++++ atsf_server/service/node_update_test.go | 9 + atsf_server/service/update.go | 101 ++++++----- atsf_server/service/update_test.go | 6 +- .../web/components/data/rank-chart.tsx | 126 ++++++++++++++ .../components/dashboard-overview.tsx | 159 ++++++++++++++++++ atsf_server/web/features/dashboard/types.ts | 38 +++++ .../nodes/components/node-detail-page.tsx | 146 ++++++++++++++++ .../tests/unit/dashboard-overview.test.tsx | 44 +++++ 9 files changed, 689 insertions(+), 57 deletions(-) create mode 100644 atsf_server/web/components/data/rank-chart.tsx diff --git a/atsf_server/service/dashboard.go b/atsf_server/service/dashboard.go index 12777a17..bab6bd66 100644 --- a/atsf_server/service/dashboard.go +++ b/atsf_server/service/dashboard.go @@ -15,6 +15,8 @@ type DashboardOverviewView struct { Traffic DashboardTraffic `json:"traffic"` Capacity DashboardCapacity `json:"capacity"` Config DashboardConfig `json:"config"` + Risk DashboardRiskSummary `json:"risk"` + Peaks DashboardPeakSummary `json:"peaks"` Trends DashboardTrends `json:"trends"` Nodes []DashboardNodeHealth `json:"nodes"` ActiveAlerts []DashboardAlert `json:"active_alerts"` @@ -52,6 +54,42 @@ type DashboardConfig struct { 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"` @@ -135,17 +173,21 @@ 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] @@ -173,6 +215,14 @@ func GetDashboardOverview() (*DashboardOverviewView, error) { 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 { @@ -189,12 +239,15 @@ 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++ } } @@ -245,6 +298,15 @@ func GetDashboardOverview() (*DashboardOverviewView, error) { 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 } @@ -304,3 +366,58 @@ func severityWeight(severity string) int { 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, + } +} diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index e5ae6057..1a11016f 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -721,6 +721,9 @@ func TestGetDashboardOverview(t *testing.T) { if view.Config.ActiveVersion != "20260314-001" || view.Config.LaggingNodes != 1 { t.Fatalf("unexpected dashboard config summary: %+v", view.Config) } + 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) } @@ -730,4 +733,10 @@ func TestGetDashboardOverview(t *testing.T) { if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 900 { t.Fatalf("unexpected dashboard traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1]) } + 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) + } } diff --git a/atsf_server/service/update.go b/atsf_server/service/update.go index ccf3dcfb..8bb2001e 100644 --- a/atsf_server/service/update.go +++ b/atsf_server/service/update.go @@ -1,4 +1,4 @@ -package service +package service import ( "atsflare/common" @@ -130,7 +130,7 @@ func ScheduleServerUpgrade(channel string) (*LatestServerRelease, error) { serverUpgradeState.Lock() if serverUpgradeState.inProgress { serverUpgradeState.Unlock() - return nil, fmt.Errorf("鏈嶅姟鍗囩骇宸插湪鎵ц涓紝璇风◢鍚庡啀璇?) + return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") } resetServerUpgradeLogsLocked() @@ -164,19 +164,18 @@ func ScheduleServerUpgrade(channel string) (*LatestServerRelease, error) { func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Reader) (*UploadedServerBinary, error) { inProgress, _, _ := snapshotServerUpgradeState() if inProgress { - return nil, fmt.Errorf("鏈嶅姟鍗囩骇宸插湪鎵ц涓紝璇风◢鍚庡啀璇?) + return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") } if strings.TrimSpace(fileName) == "" { - return nil, fmt.Errorf("缂哄皯涓婁紶鏂囦欢鍚?) + return nil, fmt.Errorf("缺少上传文件名") } if reader == nil { - return nil, fmt.Errorf("缂哄皯涓婁紶鏂囦欢鍐呭") + return nil, fmt.Errorf("缺少上传文件内容") } - execPath, err := os.Executable() if err != nil { - return nil, fmt.Errorf("鑾峰彇褰撳墠鏈嶅姟绋嬪簭璺緞澶辫触: %v", err) + return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err) } if err = verifyExecutableDirectoryWritable(execPath); err != nil { return nil, err @@ -203,7 +202,7 @@ func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Re uploadToken, err := newUpgradeToken() if err != nil { _ = os.Remove(tempPath) - return nil, fmt.Errorf("鐢熸垚鍗囩骇浠ょ墝澶辫触: %v", err) + return nil, fmt.Errorf("生成升级令牌失败: %v", err) } manualServerBinaryState.Lock() @@ -226,13 +225,13 @@ func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Re func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, error) { uploadToken = strings.TrimSpace(uploadToken) if uploadToken == "" { - return nil, fmt.Errorf("缂哄皯鍗囩骇浠ょ墝") + return nil, fmt.Errorf("缺少升级令牌") } serverUpgradeState.Lock() if serverUpgradeState.inProgress { serverUpgradeState.Unlock() - return nil, fmt.Errorf("鏈嶅姟鍗囩骇宸插湪鎵ц涓紝璇风◢鍚庡啀璇?) + return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") } serverUpgradeState.Unlock() @@ -240,11 +239,11 @@ func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, erro candidate := manualServerBinaryState.candidate if candidate == nil { manualServerBinaryState.Unlock() - return nil, fmt.Errorf("鏈壘鍒板緟纭鐨勪笂浼犲崌绾у寘锛岃閲嶆柊涓婁紶") + return nil, fmt.Errorf("未找到待确认的上传升级包,请重新上传") } if candidate.UploadToken != uploadToken { manualServerBinaryState.Unlock() - return nil, fmt.Errorf("鍗囩骇浠ょ墝鏃犳晥鎴栧凡杩囨湡锛岃閲嶆柊涓婁紶") + return nil, fmt.Errorf("升级令牌无效或已过期,请重新上传") } manualServerBinaryState.candidate = nil manualServerBinaryState.Unlock() @@ -253,7 +252,7 @@ func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, erro info.UploadToken = candidate.UploadToken if !info.ReadyToUpgrade { _ = os.Remove(candidate.TempPath) - return nil, fmt.Errorf("褰撳墠涓婁紶鐨勪簩杩涘埗涓嶆弧瓒冲崌绾ф潯浠?) + return nil, fmt.Errorf("当前上传的二进制不满足升级条件") } serverUpgradeState.Lock() @@ -292,17 +291,17 @@ func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubRe url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo)) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, fmt.Errorf("鍒涘缓鏇存柊璇锋眰澶辫触") + return nil, fmt.Errorf("创建更新请求失败") } resp, err := updateHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("鑾峰彇鏈€鏂扮増鏈け璐? %v", err) + return nil, fmt.Errorf("获取最新版本失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 杩斿洖寮傚父鐘舵€? %s", resp.Status) + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) } return decodeGitHubRelease(resp.Body) @@ -312,22 +311,22 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo)) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, fmt.Errorf("鍒涘缓鏇存柊璇锋眰澶辫触") + return nil, fmt.Errorf("创建更新请求失败") } resp, err := updateHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("鑾峰彇 preview 鐗堟湰澶辫触: %v", err) + return nil, fmt.Errorf("获取 preview 版本失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 杩斿洖寮傚父鐘舵€? %s", resp.Status) + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) } var releases []githubReleaseResponse if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil { - return nil, fmt.Errorf("瑙f瀽 preview 鐗堟湰淇℃伅澶辫触") + return nil, fmt.Errorf("解析 preview 版本信息失败") } for _, release := range releases { if release.Draft || !release.Prerelease { @@ -336,31 +335,31 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR releaseCopy := release return &releaseCopy, nil } - return nil, fmt.Errorf("褰撳墠娌℃湁鍙敤鐨?preview 鍙戝竷") + return nil, fmt.Errorf("当前没有可用的 preview 发布") } func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) { tag = strings.TrimSpace(tag) if tag == "" { - return nil, fmt.Errorf("缂哄皯鍙戝竷鐗堟湰鍙?) + return nil, fmt.Errorf("缺少发布版本号") } url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag) req, err := newGitHubReleaseRequest(ctx, url) if err != nil { - return nil, fmt.Errorf("鍒涘缓鏇存柊璇锋眰澶辫触") + return nil, fmt.Errorf("创建更新请求失败") } resp, err := updateHTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("鑾峰彇鎸囧畾鐗堟湰澶辫触: %v", err) + return nil, fmt.Errorf("获取指定版本失败: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("鏈壘鍒版寚瀹氱増鏈? %s", tag) + return nil, fmt.Errorf("未找到指定版本: %s", tag) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 杩斿洖寮傚父鐘舵€? %s", resp.Status) + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) } return decodeGitHubRelease(resp.Body) @@ -379,7 +378,7 @@ func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, er func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) { var release githubReleaseResponse if err := json.NewDecoder(reader).Decode(&release); err != nil { - return nil, fmt.Errorf("瑙f瀽鏈€鏂扮増鏈俊鎭け璐?) + return nil, fmt.Errorf("解析版本信息失败") } return &release, nil } @@ -400,9 +399,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse, channel Releas } } - serverUpgradeState.Lock() - inProgress := serverUpgradeState.inProgress - serverUpgradeState.Unlock() + inProgress, upgradeStatus, upgradeLogs := snapshotServerUpgradeState() view := &LatestServerRelease{ Channel: channel.String(), @@ -431,10 +428,10 @@ func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*prepare view := buildLatestServerReleaseView(release, channel) if !view.HasUpdate { - return nil, fmt.Errorf("褰撳墠宸叉槸鏈€鏂扮増鏈?) + return nil, fmt.Errorf("当前已经是最新版本") } if !view.UpgradeSupported { - return nil, fmt.Errorf("褰撳墠骞冲彴鏆備笉鏀寔鑷姩鍗囩骇") + return nil, fmt.Errorf("当前平台暂不支持自动升级") } assetName := serverAssetName(runtime.GOOS, runtime.GOARCH) @@ -448,19 +445,18 @@ func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*prepare } } if downloadURL == "" { - return nil, fmt.Errorf("鏈€鏂扮増鏈己灏戝綋鍓嶅钩鍙扮殑鏈嶅姟绔簩杩涘埗: %s", assetName) + return nil, fmt.Errorf("最新版本缺少当前平台的服务端二进制: %s", assetName) } execPath, err := os.Executable() if err != nil { - return nil, fmt.Errorf("鑾峰彇褰撳墠鏈嶅姟绋嬪簭璺緞澶辫触: %v", err) + return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err) } if err = verifyExecutableDirectoryWritable(execPath); err != nil { return nil, err } recordServerUpgradeLog("info", "Verified current executable directory is writable.") - return &preparedServerUpgrade{ release: view, downloadURL: downloadURL, @@ -472,15 +468,15 @@ func verifyExecutableDirectoryWritable(execPath string) error { dir := filepath.Dir(execPath) tempFile, err := os.CreateTemp(dir, "atsflare-server-upgrade-check-*") if err != nil { - return fmt.Errorf("褰撳墠鏈嶅姟浜岃繘鍒剁洰褰曚笉鍙啓锛屾棤娉曞崌绾? %v", err) + return fmt.Errorf("当前服务二进制目录不可写,无法升级: %v", err) } tempPath := tempFile.Name() if closeErr := tempFile.Close(); closeErr != nil { _ = os.Remove(tempPath) - return fmt.Errorf("鏍¢獙鏈嶅姟鍗囩骇鐩綍澶辫触: %v", closeErr) + return fmt.Errorf("校验服务升级目录失败: %v", closeErr) } if err = os.Remove(tempPath); err != nil { - return fmt.Errorf("娓呯悊鍗囩骇鏍¢獙鏂囦欢澶辫触: %v", err) + return fmt.Errorf("清理升级校验文件失败: %v", err) } return nil } @@ -504,7 +500,7 @@ func executeServerUpgrade(task *preparedServerUpgrade) error { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("涓嬭浇鏈嶅姟绔崌绾у寘澶辫触: %s", resp.Status) + return fmt.Errorf("下载服务端升级包失败: %s", resp.Status) } recordServerUpgradeLog("info", "Download finished, validating binary version.") @@ -707,14 +703,14 @@ func buildUploadedServerBinaryView(fileName string, currentVersion string, detec switch { case !upgradeSupported: - comparisonMessage = "褰撳墠鏈嶅姟鐗堟湰涓嶆敮鎸佹墜鍔ㄥ崌绾х‘璁ゆ祦绋? + comparisonMessage = "当前服务版本不支持手动升级确认流程" case normalizeVersion(currentVersion) == normalizeVersion(detectedVersion): - comparisonMessage = "涓婁紶浜岃繘鍒朵笌褰撳墠鏈嶅姟鐗堟湰涓€鑷达紝鏃犻渶鍗囩骇" + comparisonMessage = "上传二进制与当前服务版本一致,无需升级" case isVersionNewer(currentVersion, detectedVersion): hasUpdate = true - comparisonMessage = fmt.Sprintf("妫€娴嬪埌鍙崌绾х増鏈細%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion)) + comparisonMessage = fmt.Sprintf("检测到可升级版本:%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion)) default: - comparisonMessage = "涓婁紶浜岃繘鍒剁増鏈笉楂樹簬褰撳墠鏈嶅姟鐗堟湰锛屽凡鎷掔粷鍗囩骇" + comparisonMessage = "上传二进制版本不高于当前服务版本,已拒绝升级" } return &UploadedServerBinary{ @@ -745,21 +741,21 @@ func persistUploadedServerBinary(tempDir string, fileName string, reader io.Read } tempFile, err := os.CreateTemp(tempDir, "atsflare-server-manual-upgrade-*"+suffix) if err != nil { - return "", fmt.Errorf("鍒涘缓涓存椂鍗囩骇鏂囦欢澶辫触: %v", err) + return "", fmt.Errorf("创建临时升级文件失败: %v", err) } tempPath := tempFile.Name() if _, err = io.Copy(tempFile, reader); err != nil { _ = tempFile.Close() _ = os.Remove(tempPath) - return "", fmt.Errorf("鍐欏叆涓婁紶浜岃繘鍒跺け璐? %v", err) + return "", fmt.Errorf("写入上传二进制失败: %v", err) } if err = tempFile.Close(); err != nil { _ = os.Remove(tempPath) - return "", fmt.Errorf("鍏抽棴涓存椂鍗囩骇鏂囦欢澶辫触: %v", err) + return "", fmt.Errorf("关闭临时升级文件失败: %v", err) } if err = os.Chmod(tempPath, 0o755); err != nil && runtime.GOOS != "windows" { _ = os.Remove(tempPath) - return "", fmt.Errorf("璁剧疆涓存椂鍗囩骇鏂囦欢鏉冮檺澶辫触: %v", err) + return "", fmt.Errorf("设置临时升级文件权限失败: %v", err) } return tempPath, nil } @@ -772,11 +768,11 @@ func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (st cmd := exec.CommandContext(commandCtx, filePath, "--version") output, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("妫€鏌ヤ笂浼犱簩杩涘埗鐗堟湰澶辫触: %w: %s", err, strings.TrimSpace(string(output))) + return "", fmt.Errorf("检查上传二进制版本失败: %w: %s", err, strings.TrimSpace(string(output))) } version := strings.TrimSpace(string(output)) if version == "" { - return "", fmt.Errorf("涓婁紶浜岃繘鍒舵湭杩斿洖鏈夋晥鐗堟湰鍙?) + return "", fmt.Errorf("上传二进制未返回有效版本号") } for _, line := range strings.Split(version, "\n") { trimmed := strings.TrimSpace(line) @@ -784,7 +780,7 @@ func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (st return trimmed, nil } } - return "", fmt.Errorf("涓婁紶浜岃繘鍒舵湭杩斿洖鏈夋晥鐗堟湰鍙?) + return "", fmt.Errorf("上传二进制未返回有效版本号") } func persistDownloadedServerBinary(ctx context.Context, execPath string, releaseTag string, reader io.Reader) (*manualServerBinaryCandidate, error) { @@ -803,7 +799,7 @@ func persistDownloadedServerBinary(ctx context.Context, execPath string, release if normalizeVersion(detectedVersion) != normalizeVersion(releaseTag) { _ = os.Remove(tempPath) - return nil, fmt.Errorf("涓嬭浇鍖呯増鏈牎楠屽け璐ワ細release=%s锛宐inary=%s", strings.TrimSpace(releaseTag), strings.TrimSpace(detectedVersion)) + return nil, fmt.Errorf("下载包版本校验失败:release=%s,binary=%s", strings.TrimSpace(releaseTag), strings.TrimSpace(detectedVersion)) } info := buildUploadedServerBinaryView(fileName, common.Version, detectedVersion, time.Now()) @@ -924,6 +920,3 @@ func SetServerUpgradeDispatchDelayForTest(delay time.Duration) { } serverUpgradeDispatchDelay = delay } - - - diff --git a/atsf_server/service/update_test.go b/atsf_server/service/update_test.go index 8db31c7a..90425a43 100644 --- a/atsf_server/service/update_test.go +++ b/atsf_server/service/update_test.go @@ -14,9 +14,9 @@ import ( "time" ) -type roundTripFunc func(req *http.Request) (*http.Response, error) +type serverUpdateRoundTripFunc func(req *http.Request) (*http.Response, error) -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { +func (f serverUpdateRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } @@ -338,7 +338,7 @@ func TestScheduleServerUpgradeUsesDownloadedBinaryValidation(t *testing.T) { called := make(chan string, 1) SetUpdateHTTPClientForTest(&http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + Transport: serverUpdateRoundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { case "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest": return &http.Response{ diff --git a/atsf_server/web/components/data/rank-chart.tsx b/atsf_server/web/components/data/rank-chart.tsx new file mode 100644 index 00000000..815fd57a --- /dev/null +++ b/atsf_server/web/components/data/rank-chart.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useMemo } from 'react'; +import type { EChartsOption } from 'echarts'; +import ReactECharts from 'echarts-for-react'; + +type RankChartItem = { + label: string; + value: number; +}; + +type RankChartProps = { + items: RankChartItem[]; + color: string; + valueFormatter?: (value: number) => string; + emptyMessage?: string; +}; + +const defaultFormatter = (value: number) => value.toLocaleString('zh-CN'); + +export function RankChart({ + items, + color, + valueFormatter = defaultFormatter, + emptyMessage = '暂无分布数据', +}: RankChartProps) { + if (items.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + const option = useMemo( + () => ({ + animationDuration: 400, + grid: { + left: 16, + right: 24, + top: 12, + bottom: 12, + containLabel: true, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + backgroundColor: 'rgba(15, 23, 42, 0.92)', + borderWidth: 0, + textStyle: { + color: '#e2e8f0', + fontSize: 12, + }, + formatter: (params: unknown) => { + const item = Array.isArray(params) ? params[0] : params; + const data = item as { name?: string; value?: number }; + return `${data.name ?? ''}
${valueFormatter(data.value ?? 0)}`; + }, + }, + xAxis: { + type: 'value', + axisLabel: { + color: '#94a3b8', + }, + splitLine: { + lineStyle: { + color: 'rgba(148, 163, 184, 0.16)', + type: 'dashed', + }, + }, + }, + yAxis: { + type: 'category', + data: items.map((item) => item.label), + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { + color: '#cbd5e1', + width: 120, + overflow: 'truncate', + }, + }, + series: [ + { + type: 'bar', + data: items.map((item) => item.value), + barWidth: 12, + showBackground: true, + backgroundStyle: { + color: 'rgba(148, 163, 184, 0.12)', + borderRadius: 999, + }, + itemStyle: { + color, + borderRadius: 999, + }, + label: { + show: true, + position: 'right', + color: '#e2e8f0', + formatter: (params: any) => { + const rawValue = Array.isArray(params.value) + ? params.value[0] + : params.value; + return valueFormatter( + typeof rawValue === 'number' ? rawValue : 0, + ); + }, + }, + }, + ], + }), + [color, items, valueFormatter], + ); + + return ( + + ); +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 83ad8b28..41fe8e64 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -13,6 +13,7 @@ import { getDashboardOverview } from '@/features/dashboard/api/overview'; import type { DashboardAlert, DashboardNodeHealth, + DashboardPeakNode, } from '@/features/dashboard/types'; import { getNodeStatusLabel, @@ -56,6 +57,84 @@ function getAlertVariant( return 'info'; } +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 RiskSignal({ + label, + value, + tone, + hint, +}: { + label: string; + value: number; + tone: 'danger' | 'warning' | 'info' | 'success'; + hint: string; +}) { + const toneClass = + tone === 'danger' + ? 'border-rose-400/30 bg-rose-500/10 text-rose-100' + : tone === 'warning' + ? 'border-amber-400/30 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'; + + return ( +
+

{label}

+

{value}

+

{hint}

+
+ ); +} + +function PeakCard({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+

{hint}

+
+ ); +} + +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 OverviewMetric({ label, value, @@ -224,6 +303,86 @@ export function DashboardOverview() { +
+ +
+ + + + + + +
+
+ + +
+ + + + +
+
+
+
; + } + try { + const parsed = JSON.parse(value) as Record; + return Object.entries(parsed).reduce>( + (result, [key, count]) => { + if (typeof count === 'number' && Number.isFinite(count)) { + result[key] = count; + } + return result; + }, + {}, + ); + } catch { + return {} as Record; + } +} + +function aggregateTrafficBreakdown( + reports: NodeObservability['traffic_reports'], + field: 'status_codes_json' | 'top_domains_json', +) { + const summary = new Map(); + for (const report of reports) { + const parsed = parseTrafficMap(report[field]); + for (const [key, value] of Object.entries(parsed)) { + summary.set(key, (summary.get(key) ?? 0) + value); + } + } + return Array.from(summary.entries()) + .sort((left, right) => { + if (right[1] === left[1]) { + return left[0].localeCompare(right[0]); + } + return right[1] - left[1]; + }) + .slice(0, 6) + .map(([label, value]) => ({ label, value })); +} + function getHealthEventVariant( event: NodeObservability['health_events'][number], ): 'success' | 'warning' | 'danger' | 'info' { @@ -467,6 +510,22 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const activeHealthEvents = observability?.health_events.filter((event) => event.status === 'active') ?? []; + const statusCodeDistribution = useMemo( + () => + aggregateTrafficBreakdown( + observability?.traffic_reports ?? [], + 'status_codes_json', + ), + [observability?.traffic_reports], + ); + const topDomains = useMemo( + () => + aggregateTrafficBreakdown( + observability?.traffic_reports ?? [], + 'top_domains_json', + ), + [observability?.traffic_reports], + ); const memoryUsageRatio = formatUsageRatio( latestMetricSnapshot?.memory_used_bytes, latestMetricSnapshot?.memory_total_bytes, @@ -908,6 +967,93 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
+
+ +
+
+

+ 状态码分布 +

+ +
+
+

+ Top Domain +

+ +
+
+
+ + + {observability?.health_events.length ? ( +
+ {observability.health_events.slice(0, 8).map((event) => ( +
+
+ + +
+

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

+
+

+ 首次触发: + {isMeaningfulTime(event.first_triggered_at) + ? ` ${formatDateTime(event.first_triggered_at)}` + : ' —'} +

+

+ 最近触发: + {isMeaningfulTime(event.last_triggered_at) + ? ` ${formatDateTime(event.last_triggered_at)}` + : ' —'} +

+

+ 恢复时间: + {isMeaningfulTime(event.resolved_at) + ? ` ${formatDateTime(event.resolved_at)}` + : ' —'} +

+
+
+ ))} +
+ ) : ( + + )} +
+
+
diff --git a/atsf_server/web/tests/unit/dashboard-overview.test.tsx b/atsf_server/web/tests/unit/dashboard-overview.test.tsx index d8d7189d..440ef301 100644 --- a/atsf_server/web/tests/unit/dashboard-overview.test.tsx +++ b/atsf_server/web/tests/unit/dashboard-overview.test.tsx @@ -55,6 +55,49 @@ describe('DashboardOverview', () => { 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, + }, + }, trends: { traffic_24h: Array.from({ length: 24 }, (_, index) => ({ bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, @@ -123,6 +166,7 @@ describe('DashboardOverview', () => { ); expect(await screen.findByText('系统运行总览')).toBeInTheDocument(); + expect(await screen.findByText('风险态势')).toBeInTheDocument(); expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument(); expect(await screen.findByText('节点健康列表')).toBeInTheDocument(); });