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.
This commit is contained in:
ryan
2026-03-14 12:12:38 +08:00
parent 16bb9e5879
commit 497289b462
9 changed files with 689 additions and 57 deletions
+117
View File
@@ -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,
}
}
+9
View File
@@ -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)
}
}
+47 -54
View File
@@ -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
}
+3 -3
View File
@@ -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{
@@ -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 (
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
{emptyMessage}
</div>
);
}
const option = useMemo<EChartsOption>(
() => ({
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 ?? ''}<br/>${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 (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
/>
);
}
@@ -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 (
<div className={`rounded-3xl border px-4 py-4 ${toneClass}`}>
<p className="text-xs tracking-[0.22em] uppercase opacity-75">{label}</p>
<p className="mt-3 text-3xl font-semibold">{value}</p>
<p className="mt-2 text-sm opacity-80">{hint}</p>
</div>
);
}
function PeakCard({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<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>
);
}
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() {
</div>
</AppCard>
<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">
<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="当前版本未追平全局激活配置"
/>
<RiskSignal
label="OpenResty 异常"
value={overview.risk.unhealthy_nodes}
tone="danger"
hint="运行态已出现不健康节点"
/>
<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.high_storage_nodes}
tone="warning"
hint={`${overview.risk.offline_nodes} 个离线节点`}
/>
</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}
/>
<PeakCard
label="优先排查节点"
value={formatPeakNode(overview.peaks.riskiest_node).title}
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
/>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
@@ -30,6 +30,42 @@ export interface DashboardConfig {
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 TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
@@ -82,6 +118,8 @@ export interface DashboardOverview {
traffic: DashboardTraffic;
capacity: DashboardCapacity;
config: DashboardConfig;
risk: DashboardRiskSummary;
peaks: DashboardPeakSummary;
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
active_alerts: DashboardAlert[];
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
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';
@@ -152,6 +153,48 @@ function formatTrendHour(value: string) {
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function parseTrafficMap(value?: string | null) {
if (!value) {
return {} as Record<string, number>;
}
try {
const parsed = JSON.parse(value) as Record<string, number>;
return Object.entries(parsed).reduce<Record<string, number>>(
(result, [key, count]) => {
if (typeof count === 'number' && Number.isFinite(count)) {
result[key] = count;
}
return result;
},
{},
);
} catch {
return {} as Record<string, number>;
}
}
function aggregateTrafficBreakdown(
reports: NodeObservability['traffic_reports'],
field: 'status_codes_json' | 'top_domains_json',
) {
const summary = new Map<string, number>();
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 }) {
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<AppCard
title="请求结构分布"
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
>
<div className="grid gap-6 xl:grid-cols-2">
<div>
<p className="mb-4 text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
状态码分布
</p>
<RankChart
items={statusCodeDistribution}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</div>
<div>
<p className="mb-4 text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Top Domain
</p>
<RankChart
items={topDomains}
color="#2563eb"
emptyMessage="暂无域名分布"
/>
</div>
</div>
</AppCard>
<AppCard
title="健康事件时间线"
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。"
>
{observability?.health_events.length ? (
<div className="space-y-4">
{observability.health_events.slice(0, 8).map((event) => (
<div
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={getHealthEventLabel(event)}
variant={getHealthEventVariant(event)}
/>
<StatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'}
variant={
event.status === 'active' ? 'warning' : 'success'
}
/>
</div>
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
{event.message || '暂无详细消息'}
</p>
<div className="mt-3 grid gap-2 text-xs text-[var(--foreground-muted)] md:grid-cols-3">
<p>
首次触发:
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
最近触发:
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
恢复时间:
{isMeaningfulTime(event.resolved_at)
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
</p>
</div>
</div>
))}
</div>
) : (
<EmptyState
title="暂无健康事件时间线"
description="节点当前还没有上报可展示的健康事件记录。"
/>
)}
</AppCard>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<AppCard title="更新模式">
<div className="space-y-3">
@@ -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();
});