mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat: add dashboard overview health summary
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDashboardOverview godoc
|
||||||
|
// @Summary Get dashboard overview
|
||||||
|
// @Tags Dashboard
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 400 {object} map[string]interface{}
|
||||||
|
// @Router /api/dashboard/overview [get]
|
||||||
|
func GetDashboardOverview(c *gin.Context) {
|
||||||
|
view, err := service.GetDashboardOverview()
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, view)
|
||||||
|
}
|
||||||
@@ -109,6 +109,11 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
|
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
|
||||||
configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion)
|
configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion)
|
||||||
}
|
}
|
||||||
|
dashboardRoute := apiRouter.Group("/dashboard")
|
||||||
|
dashboardRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
dashboardRoute.GET("/overview", controller.GetDashboardOverview)
|
||||||
|
}
|
||||||
nodeRoute := apiRouter.Group("/nodes")
|
nodeRoute := apiRouter.Group("/nodes")
|
||||||
nodeRoute.Use(middleware.AdminAuth())
|
nodeRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
createRouteAndPublishVersion(t, engine, adminToken)
|
createRouteAndPublishVersion(t, engine, adminToken)
|
||||||
|
|
||||||
|
dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil)
|
||||||
|
var dashboard service.DashboardOverviewView
|
||||||
|
decodeResponseData(t, dashboardResp, &dashboard)
|
||||||
|
if dashboard.Summary.TotalNodes != 0 {
|
||||||
|
t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
unauthorizedRequest := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/register", bytes.NewReader([]byte(`{}`)))
|
unauthorizedRequest := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/register", bytes.NewReader([]byte(`{}`)))
|
||||||
unauthorizedRecorder := httptest.NewRecorder()
|
unauthorizedRecorder := httptest.NewRecorder()
|
||||||
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/model"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardOverviewView struct {
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Summary DashboardSummary `json:"summary"`
|
||||||
|
Traffic DashboardTraffic `json:"traffic"`
|
||||||
|
Capacity DashboardCapacity `json:"capacity"`
|
||||||
|
Config DashboardConfig `json:"config"`
|
||||||
|
Nodes []DashboardNodeHealth `json:"nodes"`
|
||||||
|
ActiveAlerts []DashboardAlert `json:"active_alerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardSummary struct {
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
OnlineNodes int `json:"online_nodes"`
|
||||||
|
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 {
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
UniqueVisitors int64 `json:"unique_visitors"`
|
||||||
|
ErrorCount int64 `json:"error_count"`
|
||||||
|
EstimatedQPS float64 `json:"estimated_qps"`
|
||||||
|
ReportedNodes int `json:"reported_nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardCapacity struct {
|
||||||
|
AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"`
|
||||||
|
AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"`
|
||||||
|
HighCPUNodes int `json:"high_cpu_nodes"`
|
||||||
|
HighMemoryNodes int `json:"high_memory_nodes"`
|
||||||
|
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 DashboardNodeHealth struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OpenrestyStatus string `json:"openresty_status"`
|
||||||
|
CurrentVersion string `json:"current_version"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
ActiveEventCount int `json:"active_event_count"`
|
||||||
|
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||||
|
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||||
|
StorageUsagePercent float64 `json:"storage_usage_percent"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
ErrorCount int64 `json:"error_count"`
|
||||||
|
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)
|
||||||
|
|
||||||
|
nodes, err := model.ListNodes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
activeVersion := ""
|
||||||
|
if version, versionErr := model.GetActiveConfigVersion(); versionErr == nil {
|
||||||
|
activeVersion = version.Version
|
||||||
|
} else if !errors.Is(versionErr, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, versionErr
|
||||||
|
}
|
||||||
|
|
||||||
|
view := &DashboardOverviewView{
|
||||||
|
GeneratedAt: now,
|
||||||
|
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var cpuNodeCount int
|
||||||
|
var memoryNodeCount int
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
computedStatus := computeNodeStatus(node)
|
||||||
|
switch computedStatus {
|
||||||
|
case NodeStatusOnline:
|
||||||
|
view.Summary.OnlineNodes++
|
||||||
|
case NodeStatusOffline:
|
||||||
|
view.Summary.OfflineNodes++
|
||||||
|
case NodeStatusPending:
|
||||||
|
view.Summary.PendingNodes++
|
||||||
|
}
|
||||||
|
if node.OpenrestyStatus == OpenrestyStatusUnhealthy {
|
||||||
|
view.Summary.UnhealthyNodes++
|
||||||
|
}
|
||||||
|
if activeVersion != "" && node.CurrentVersion != "" && node.CurrentVersion != activeVersion {
|
||||||
|
view.Summary.LaggingNodes++
|
||||||
|
}
|
||||||
|
if activeVersion != "" && node.CurrentVersion == "" && computedStatus != NodeStatusPending {
|
||||||
|
view.Summary.LaggingNodes++
|
||||||
|
}
|
||||||
|
|
||||||
|
latestSnapshot := latestMetricSnapshotForNode(node.NodeID, since)
|
||||||
|
latestTraffic := latestTrafficReportForNode(node.NodeID, since)
|
||||||
|
activeEvents, eventErr := model.ListNodeHealthEvents(node.NodeID, true, 20)
|
||||||
|
if eventErr != nil {
|
||||||
|
return nil, eventErr
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeHealth := DashboardNodeHealth{
|
||||||
|
ID: node.ID,
|
||||||
|
NodeID: node.NodeID,
|
||||||
|
Name: node.Name,
|
||||||
|
Status: computedStatus,
|
||||||
|
OpenrestyStatus: node.OpenrestyStatus,
|
||||||
|
CurrentVersion: node.CurrentVersion,
|
||||||
|
LastSeenAt: node.LastSeenAt,
|
||||||
|
ActiveEventCount: len(activeEvents),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range activeEvents {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestSnapshot != nil {
|
||||||
|
nodeHealth.CPUUsagePercent = latestSnapshot.CPUUsagePercent
|
||||||
|
nodeHealth.MemoryUsagePercent = percentage(latestSnapshot.MemoryUsedBytes, latestSnapshot.MemoryTotalBytes)
|
||||||
|
nodeHealth.StorageUsagePercent = percentage(latestSnapshot.StorageUsedBytes, latestSnapshot.StorageTotalBytes)
|
||||||
|
if latestSnapshot.CPUUsagePercent > 0 {
|
||||||
|
view.Capacity.AverageCPUUsagePercent += latestSnapshot.CPUUsagePercent
|
||||||
|
cpuNodeCount++
|
||||||
|
}
|
||||||
|
if nodeHealth.MemoryUsagePercent > 0 {
|
||||||
|
view.Capacity.AverageMemoryUsagePercent += nodeHealth.MemoryUsagePercent
|
||||||
|
memoryNodeCount++
|
||||||
|
}
|
||||||
|
if latestSnapshot.CPUUsagePercent >= 80 {
|
||||||
|
view.Capacity.HighCPUNodes++
|
||||||
|
}
|
||||||
|
if nodeHealth.MemoryUsagePercent >= 85 {
|
||||||
|
view.Capacity.HighMemoryNodes++
|
||||||
|
}
|
||||||
|
if nodeHealth.StorageUsagePercent >= 85 {
|
||||||
|
view.Capacity.HighStorageNodes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestTraffic != nil {
|
||||||
|
nodeHealth.RequestCount = latestTraffic.RequestCount
|
||||||
|
nodeHealth.ErrorCount = latestTraffic.ErrorCount
|
||||||
|
nodeHealth.UniqueVisitorCount = latestTraffic.UniqueVisitorCount
|
||||||
|
view.Traffic.RequestCount += latestTraffic.RequestCount
|
||||||
|
view.Traffic.UniqueVisitors += latestTraffic.UniqueVisitorCount
|
||||||
|
view.Traffic.ErrorCount += latestTraffic.ErrorCount
|
||||||
|
if duration := latestTraffic.WindowEndedAt.Sub(latestTraffic.WindowStartedAt).Seconds(); duration > 0 {
|
||||||
|
view.Traffic.EstimatedQPS += float64(latestTraffic.RequestCount) / duration
|
||||||
|
}
|
||||||
|
view.Traffic.ReportedNodes++
|
||||||
|
}
|
||||||
|
|
||||||
|
view.Summary.ActiveAlerts += len(activeEvents)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if memoryNodeCount > 0 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return view.Nodes[i].ActiveEventCount > view.Nodes[j].ActiveEventCount
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(view.ActiveAlerts) > 8 {
|
||||||
|
view.ActiveAlerts = view.ActiveAlerts[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestMetricSnapshotForNode(nodeID string, since time.Time) *model.NodeMetricSnapshot {
|
||||||
|
snapshots, err := model.ListNodeMetricSnapshots(nodeID, since, 1)
|
||||||
|
if err != nil || len(snapshots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return snapshots[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestTrafficReportForNode(nodeID string, since time.Time) *model.NodeRequestReport {
|
||||||
|
reports, err := model.ListNodeRequestReports(nodeID, since, 1)
|
||||||
|
if err != nil || len(reports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return reports[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentage(used int64, total int64) float64 {
|
||||||
|
if used <= 0 || total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (float64(used) / float64(total)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityWeight(severity string) int {
|
||||||
|
switch severity {
|
||||||
|
case NodeHealthSeverityCritical:
|
||||||
|
return 3
|
||||||
|
case NodeHealthSeverityWarning:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -590,3 +590,129 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
|||||||
t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile)
|
t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetDashboardOverview(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := model.DB.Create(&model.ConfigVersion{
|
||||||
|
Version: "20260314-001",
|
||||||
|
SnapshotJSON: "{}",
|
||||||
|
MainConfig: "worker_processes auto;",
|
||||||
|
RenderedConfig: "server { listen 80; }",
|
||||||
|
Checksum: "checksum-dashboard",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedBy: "root",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed active config version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := []*model.Node{
|
||||||
|
{
|
||||||
|
NodeID: "node-dashboard-a",
|
||||||
|
Name: "edge-a",
|
||||||
|
IP: "10.0.0.71",
|
||||||
|
AgentToken: "token-a",
|
||||||
|
AgentVersion: "v0.6.0",
|
||||||
|
NginxVersion: "1.27.1.2",
|
||||||
|
OpenrestyStatus: OpenrestyStatusHealthy,
|
||||||
|
Status: NodeStatusOnline,
|
||||||
|
CurrentVersion: "20260314-001",
|
||||||
|
LastSeenAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NodeID: "node-dashboard-b",
|
||||||
|
Name: "edge-b",
|
||||||
|
IP: "10.0.0.72",
|
||||||
|
AgentToken: "token-b",
|
||||||
|
AgentVersion: "v0.6.0",
|
||||||
|
NginxVersion: "1.27.1.2",
|
||||||
|
OpenrestyStatus: OpenrestyStatusUnhealthy,
|
||||||
|
Status: NodeStatusOnline,
|
||||||
|
CurrentVersion: "20260313-001",
|
||||||
|
LastSeenAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, node := range nodes {
|
||||||
|
if err := node.Insert(); err != nil {
|
||||||
|
t.Fatalf("failed to insert node %s: %v", node.NodeID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&model.NodeMetricSnapshot{
|
||||||
|
NodeID: "node-dashboard-a",
|
||||||
|
CapturedAt: now,
|
||||||
|
CPUUsagePercent: 45,
|
||||||
|
MemoryUsedBytes: 4 * 1024 * 1024 * 1024,
|
||||||
|
MemoryTotalBytes: 8 * 1024 * 1024 * 1024,
|
||||||
|
StorageUsedBytes: 50 * 1024 * 1024 * 1024,
|
||||||
|
StorageTotalBytes: 100 * 1024 * 1024 * 1024,
|
||||||
|
}).Insert(); err != nil {
|
||||||
|
t.Fatalf("failed to insert node a metric snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := (&model.NodeMetricSnapshot{
|
||||||
|
NodeID: "node-dashboard-b",
|
||||||
|
CapturedAt: now,
|
||||||
|
CPUUsagePercent: 92,
|
||||||
|
MemoryUsedBytes: 15 * 1024 * 1024 * 1024,
|
||||||
|
MemoryTotalBytes: 16 * 1024 * 1024 * 1024,
|
||||||
|
StorageUsedBytes: 95 * 1024 * 1024 * 1024,
|
||||||
|
StorageTotalBytes: 100 * 1024 * 1024 * 1024,
|
||||||
|
}).Insert(); err != nil {
|
||||||
|
t.Fatalf("failed to insert node b metric snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&model.NodeRequestReport{
|
||||||
|
NodeID: "node-dashboard-a",
|
||||||
|
WindowStartedAt: now.Add(-time.Minute),
|
||||||
|
WindowEndedAt: now,
|
||||||
|
RequestCount: 600,
|
||||||
|
ErrorCount: 6,
|
||||||
|
UniqueVisitorCount: 120,
|
||||||
|
}).Insert(); err != nil {
|
||||||
|
t.Fatalf("failed to insert node a traffic report: %v", err)
|
||||||
|
}
|
||||||
|
if err := (&model.NodeRequestReport{
|
||||||
|
NodeID: "node-dashboard-b",
|
||||||
|
WindowStartedAt: now.Add(-time.Minute),
|
||||||
|
WindowEndedAt: now,
|
||||||
|
RequestCount: 300,
|
||||||
|
ErrorCount: 30,
|
||||||
|
UniqueVisitorCount: 80,
|
||||||
|
}).Insert(); err != nil {
|
||||||
|
t.Fatalf("failed to insert node b traffic report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DB.Create(&model.NodeHealthEvent{
|
||||||
|
NodeID: "node-dashboard-b",
|
||||||
|
EventType: "openresty_unhealthy",
|
||||||
|
Severity: NodeHealthSeverityCritical,
|
||||||
|
Status: NodeHealthEventStatusActive,
|
||||||
|
Message: "reload failed",
|
||||||
|
FirstTriggeredAt: now.Add(-time.Minute),
|
||||||
|
LastTriggeredAt: now,
|
||||||
|
ReportedAt: now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to insert dashboard health event: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := GetDashboardOverview()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDashboardOverview failed: %v", err)
|
||||||
|
}
|
||||||
|
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.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 len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
|
||||||
|
t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type { DashboardOverview } from '@/features/dashboard/types';
|
||||||
|
|
||||||
|
export function getDashboardOverview() {
|
||||||
|
return apiRequest<DashboardOverview>('/dashboard/overview');
|
||||||
|
}
|
||||||
@@ -1,295 +1,311 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
import { getDashboardOverview } from '@/features/dashboard/api/overview';
|
||||||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
import type {
|
||||||
import type { ManagedDomainItem } from '@/features/managed-domains/types';
|
DashboardAlert,
|
||||||
import { getNodes } from '@/features/nodes/api/nodes';
|
DashboardNodeHealth,
|
||||||
import type { NodeItem } from '@/features/nodes/types';
|
} from '@/features/dashboard/types';
|
||||||
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
import {
|
||||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
getNodeStatusLabel,
|
||||||
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
getNodeStatusVariant,
|
||||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
getOpenrestyStatusLabel,
|
||||||
import { getUsers, searchUsers } from '@/features/users/api/users';
|
getOpenrestyStatusVariant,
|
||||||
import type { UserItem } from '@/features/users/types';
|
} from '@/features/nodes/utils';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
function parseCustomHeaders(rawValue: string) {
|
function formatPercent(value?: number | null) {
|
||||||
if (!rawValue) {
|
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||||
return [];
|
return '—';
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryCard({
|
function getAlertVariant(
|
||||||
title,
|
alert: DashboardAlert,
|
||||||
description,
|
): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
href,
|
if (alert.status === 'resolved') {
|
||||||
items,
|
return 'success';
|
||||||
status,
|
}
|
||||||
error,
|
if (alert.severity === 'critical') {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
if (alert.severity === 'warning') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewMetric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
label: string;
|
||||||
description: string;
|
value: string | number;
|
||||||
href: string;
|
hint?: string;
|
||||||
items: ReadonlyArray<{ label: string; value: string | number }>;
|
|
||||||
status: 'pending' | 'success' | 'error';
|
|
||||||
error?: unknown;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 py-5">
|
||||||
title={title}
|
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
|
||||||
description={description}
|
{label}
|
||||||
action={
|
</p>
|
||||||
<Link
|
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||||
href={href}
|
{value}
|
||||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
</p>
|
||||||
>
|
{hint ? (
|
||||||
进入模块
|
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||||
</Link>
|
{hint}
|
||||||
}
|
</p>
|
||||||
>
|
) : null}
|
||||||
{status === 'pending' ? (
|
</div>
|
||||||
<LoadingState />
|
|
||||||
) : status === 'error' ? (
|
|
||||||
<ErrorState
|
|
||||||
title={`${title}加载失败`}
|
|
||||||
description={getErrorMessage(error)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4"
|
|
||||||
>
|
|
||||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardOverview() {
|
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||||
const [
|
|
||||||
nodesQuery,
|
|
||||||
versionsQuery,
|
|
||||||
managedDomainsQuery,
|
|
||||||
certificatesQuery,
|
|
||||||
proxyRoutesQuery,
|
|
||||||
usersQuery,
|
|
||||||
] = useQueries({
|
|
||||||
queries: [
|
|
||||||
{ queryKey: ['dashboard', 'nodes'], queryFn: getNodes },
|
|
||||||
{
|
|
||||||
queryKey: ['dashboard', 'config-versions'],
|
|
||||||
queryFn: getConfigVersions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
queryKey: ['dashboard', 'managed-domains'],
|
|
||||||
queryFn: getManagedDomains,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
queryKey: ['dashboard', 'tls-certificates'],
|
|
||||||
queryFn: getTlsCertificates,
|
|
||||||
},
|
|
||||||
{ queryKey: ['dashboard', 'proxy-routes'], queryFn: getProxyRoutes },
|
|
||||||
{
|
|
||||||
queryKey: ['dashboard', 'users'],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return await searchUsers('');
|
|
||||||
} catch {
|
|
||||||
return getUsers(0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodes = (nodesQuery.data ?? []) as NodeItem[];
|
|
||||||
const versions = (versionsQuery.data ?? []) as ConfigVersionItem[];
|
|
||||||
const domains = (managedDomainsQuery.data ?? []) as ManagedDomainItem[];
|
|
||||||
const certificates = (certificatesQuery.data ?? []) as TlsCertificateItem[];
|
|
||||||
const routes = (proxyRoutesQuery.data ?? []) as ProxyRouteItem[];
|
|
||||||
const users = (usersQuery.data ?? []) as UserItem[];
|
|
||||||
|
|
||||||
const expiringCertificates = certificates.filter((item) => {
|
|
||||||
const expiresAt = new Date(item.not_after).getTime();
|
|
||||||
return (
|
|
||||||
!Number.isNaN(expiresAt) &&
|
|
||||||
expiresAt >= Date.now() &&
|
|
||||||
expiresAt - Date.now() <= 30 * 24 * 60 * 60 * 1000
|
|
||||||
);
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const expiredCertificates = certificates.filter((item) => {
|
|
||||||
const expiresAt = new Date(item.not_after).getTime();
|
|
||||||
return !Number.isNaN(expiresAt) && expiresAt < Date.now();
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const dashboardCards = [
|
|
||||||
{
|
|
||||||
title: '节点摘要',
|
|
||||||
description: '集中查看在线状态、待接入节点和自动更新覆盖情况。',
|
|
||||||
href: '/node',
|
|
||||||
status: nodesQuery.status,
|
|
||||||
error: nodesQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '节点总数', value: nodes.length },
|
|
||||||
{
|
|
||||||
label: '在线节点',
|
|
||||||
value: nodes.filter((item) => item.status === 'online').length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '待接入节点',
|
|
||||||
value: nodes.filter((item) => item.status === 'pending').length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '自动更新',
|
|
||||||
value: nodes.filter((item) => item.auto_update_enabled).length,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '版本摘要',
|
|
||||||
description: '直接感知当前发布沉淀、激活版本和最近一次变更时间。',
|
|
||||||
href: '/config-version',
|
|
||||||
status: versionsQuery.status,
|
|
||||||
error: versionsQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '版本总数', value: versions.length },
|
|
||||||
{
|
|
||||||
label: '激活版本',
|
|
||||||
value: versions.filter((item) => item.is_active).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '最近创建',
|
|
||||||
value: versions[0]?.created_at
|
|
||||||
? formatDateTime(versions[0].created_at)
|
|
||||||
: '—',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '规则摘要',
|
|
||||||
description: '查看域名规则启用状态、通配符覆盖和证书绑定规模。',
|
|
||||||
href: '/website',
|
|
||||||
status: managedDomainsQuery.status,
|
|
||||||
error: managedDomainsQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '域名规则', value: domains.length },
|
|
||||||
{
|
|
||||||
label: '已启用',
|
|
||||||
value: domains.filter((item) => item.enabled).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '通配符规则',
|
|
||||||
value: domains.filter((item) => item.domain.startsWith('*.')).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '已绑证书',
|
|
||||||
value: domains.filter((item) => item.cert_id).length,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '证书概览',
|
|
||||||
description: '快速识别证书存量、近 30 天到期风险和过期情况。',
|
|
||||||
href: '/website',
|
|
||||||
status: certificatesQuery.status,
|
|
||||||
error: certificatesQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '证书总数', value: certificates.length },
|
|
||||||
{ label: '30 天内到期', value: expiringCertificates },
|
|
||||||
{ label: '已过期', value: expiredCertificates },
|
|
||||||
{
|
|
||||||
label: '最近更新',
|
|
||||||
value: certificates[0]?.updated_at
|
|
||||||
? formatDateTime(certificates[0].updated_at)
|
|
||||||
: '—',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '发布与摘要',
|
|
||||||
description: '汇总反向代理规则规模、HTTPS 覆盖和请求头配置密度。',
|
|
||||||
href: '/proxy-route',
|
|
||||||
status: proxyRoutesQuery.status,
|
|
||||||
error: proxyRoutesQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '规则总数', value: routes.length },
|
|
||||||
{
|
|
||||||
label: '已启用',
|
|
||||||
value: routes.filter((item) => item.enabled).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'HTTPS 规则',
|
|
||||||
value: routes.filter((item) => item.enable_https).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '自定义请求头',
|
|
||||||
value: routes.reduce(
|
|
||||||
(count, route) =>
|
|
||||||
count + parseCustomHeaders(route.custom_headers).length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '用户概览',
|
|
||||||
description: '展示当前可管理用户池的角色和状态分布。',
|
|
||||||
href: '/user',
|
|
||||||
status: usersQuery.status,
|
|
||||||
error: usersQuery.error,
|
|
||||||
items: [
|
|
||||||
{ label: '用户总数', value: users.length },
|
|
||||||
{
|
|
||||||
label: '管理员',
|
|
||||||
value: users.filter((item) => item.role >= 10).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '已激活',
|
|
||||||
value: users.filter((item) => item.status === 1).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '已封禁',
|
|
||||||
value: users.filter((item) => item.status !== 1).length,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
{dashboardCards.map((card) => (
|
<div>
|
||||||
<SummaryCard key={card.title} {...card} />
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
))}
|
{node.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||||
|
{node.node_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatusBadge
|
||||||
|
label={getNodeStatusLabel(node.status)}
|
||||||
|
variant={getNodeStatusVariant(node.status)}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||||
|
variant={getOpenrestyStatusVariant(node.openresty_status)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
CPU
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
|
{formatPercent(node.cpu_usage_percent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
内存
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
|
{formatPercent(node.memory_usage_percent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
最近窗口请求
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
|
{node.request_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
活动异常
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
|
{node.active_event_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||||
|
<p>当前版本:{node.current_version || '未应用'}</p>
|
||||||
|
<p>
|
||||||
|
最近心跳:
|
||||||
|
{node.last_seen_at
|
||||||
|
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||||
|
: ' 暂无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardOverview() {
|
||||||
|
const overviewQuery = useQuery({
|
||||||
|
queryKey: ['dashboard', 'overview'],
|
||||||
|
queryFn: getDashboardOverview,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overviewQuery.isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overviewQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="总览看板加载失败"
|
||||||
|
description={getErrorMessage(overviewQuery.error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = overviewQuery.data;
|
||||||
|
if (!overview) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无总览数据"
|
||||||
|
description="系统已经启动,但还没有可展示的总览聚合结果。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AppCard
|
||||||
|
title="系统运行总览"
|
||||||
|
description="优先回答当前系统是否健康、容量是否紧张、是否存在配置落后和异常节点。"
|
||||||
|
action={
|
||||||
|
<div className="text-right text-sm text-[var(--foreground-secondary)]">
|
||||||
|
生成时间:{formatDateTime(overview.generated_at)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-5">
|
||||||
|
<OverviewMetric
|
||||||
|
label="在线节点"
|
||||||
|
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
|
||||||
|
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="活动异常"
|
||||||
|
value={overview.summary.active_alerts}
|
||||||
|
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="配置落后"
|
||||||
|
value={overview.summary.lagging_nodes}
|
||||||
|
hint={overview.config.active_version || '当前无激活版本'}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="最近窗口请求"
|
||||||
|
value={overview.traffic.request_count}
|
||||||
|
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors}`}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="平均 CPU"
|
||||||
|
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
|
||||||
|
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<AppCard
|
||||||
|
title="节点健康列表"
|
||||||
|
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/node"
|
||||||
|
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
|
>
|
||||||
|
进入节点页
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{overview.nodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无节点"
|
||||||
|
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{overview.nodes.slice(0, 8).map((node) => (
|
||||||
|
<NodeHealthRow key={node.node_id} node={node} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AppCard
|
||||||
|
title="流量与容量"
|
||||||
|
description="聚合最近窗口流量和资源压力,作为首页一级信号。"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<OverviewMetric
|
||||||
|
label="最近窗口错误"
|
||||||
|
value={overview.traffic.error_count}
|
||||||
|
hint={`${overview.traffic.reported_nodes} 个节点上报`}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="平均内存"
|
||||||
|
value={formatPercent(
|
||||||
|
overview.capacity.average_memory_usage_percent,
|
||||||
|
)}
|
||||||
|
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
|
||||||
|
/>
|
||||||
|
<OverviewMetric
|
||||||
|
label="高存储压力"
|
||||||
|
value={overview.capacity.high_storage_nodes}
|
||||||
|
hint="存储使用率 >= 85%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="活动异常"
|
||||||
|
description="优先展示当前仍在触发中的问题。"
|
||||||
|
>
|
||||||
|
{overview.active_alerts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无活动异常"
|
||||||
|
description="当前系统没有正在触发的节点健康事件。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{overview.active_alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
|
||||||
|
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={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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export interface DashboardSummary {
|
||||||
|
total_nodes: number;
|
||||||
|
online_nodes: number;
|
||||||
|
offline_nodes: number;
|
||||||
|
pending_nodes: number;
|
||||||
|
unhealthy_nodes: number;
|
||||||
|
active_alerts: number;
|
||||||
|
lagging_nodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTraffic {
|
||||||
|
request_count: number;
|
||||||
|
unique_visitors: number;
|
||||||
|
error_count: number;
|
||||||
|
estimated_qps: number;
|
||||||
|
reported_nodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCapacity {
|
||||||
|
average_cpu_usage_percent: number;
|
||||||
|
average_memory_usage_percent: number;
|
||||||
|
high_cpu_nodes: number;
|
||||||
|
high_memory_nodes: number;
|
||||||
|
high_storage_nodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardConfig {
|
||||||
|
active_version: string;
|
||||||
|
lagging_nodes: number;
|
||||||
|
pending_nodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNodeHealth {
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'online' | 'offline' | 'pending';
|
||||||
|
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
current_version: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
active_event_count: number;
|
||||||
|
cpu_usage_percent: number;
|
||||||
|
memory_usage_percent: number;
|
||||||
|
storage_usage_percent: number;
|
||||||
|
request_count: number;
|
||||||
|
error_count: number;
|
||||||
|
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;
|
||||||
|
nodes: DashboardNodeHealth[];
|
||||||
|
active_alerts: DashboardAlert[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user