diff --git a/atsf_server/controller/dashboard.go b/atsf_server/controller/dashboard.go new file mode 100644 index 00000000..541ffc55 --- /dev/null +++ b/atsf_server/controller/dashboard.go @@ -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) +} diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 73da2c46..5fdc8f53 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -109,6 +109,11 @@ func SetApiRouter(router *gin.Engine) { configVersionRoute.POST("/publish", controller.PublishConfigVersion) configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion) } + dashboardRoute := apiRouter.Group("/dashboard") + dashboardRoute.Use(middleware.AdminAuth()) + { + dashboardRoute.GET("/overview", controller.GetDashboardOverview) + } nodeRoute := apiRouter.Group("/nodes") nodeRoute.Use(middleware.AdminAuth()) { diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index 10b442da..e7084f24 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -169,6 +169,13 @@ func TestPhase2AgentLifecycle(t *testing.T) { 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(`{}`))) unauthorizedRecorder := httptest.NewRecorder() engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest) diff --git a/atsf_server/service/dashboard.go b/atsf_server/service/dashboard.go new file mode 100644 index 00000000..d29d7904 --- /dev/null +++ b/atsf_server/service/dashboard.go @@ -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 + } +} diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index 6435bc45..a550f93e 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -590,3 +590,129 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) { 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) + } +} diff --git a/atsf_server/web/features/dashboard/api/overview.ts b/atsf_server/web/features/dashboard/api/overview.ts new file mode 100644 index 00000000..2c7616ac --- /dev/null +++ b/atsf_server/web/features/dashboard/api/overview.ts @@ -0,0 +1,7 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { DashboardOverview } from '@/features/dashboard/types'; + +export function getDashboardOverview() { + return apiRequest('/dashboard/overview'); +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 77a9dcb2..8b57a6b5 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -1,295 +1,311 @@ 'use client'; 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 { LoadingState } from '@/components/feedback/loading-state'; import { AppCard } from '@/components/ui/app-card'; -import { getConfigVersions } from '@/features/config-versions/api/config-versions'; -import type { ConfigVersionItem } from '@/features/config-versions/types'; -import { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; -import type { ManagedDomainItem } from '@/features/managed-domains/types'; -import { getNodes } from '@/features/nodes/api/nodes'; -import type { NodeItem } from '@/features/nodes/types'; -import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; -import type { ProxyRouteItem } from '@/features/proxy-routes/types'; -import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates'; -import type { TlsCertificateItem } from '@/features/tls-certificates/types'; -import { getUsers, searchUsers } from '@/features/users/api/users'; -import type { UserItem } from '@/features/users/types'; -import { formatDateTime } from '@/lib/utils/date'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getDashboardOverview } from '@/features/dashboard/api/overview'; +import type { + DashboardAlert, + DashboardNodeHealth, +} from '@/features/dashboard/types'; +import { + getNodeStatusLabel, + getNodeStatusVariant, + getOpenrestyStatusLabel, + getOpenrestyStatusVariant, +} from '@/features/nodes/utils'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; -function parseCustomHeaders(rawValue: string) { - if (!rawValue) { - return []; - } - - try { - const parsed = JSON.parse(rawValue); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; +function formatPercent(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; } + return `${value.toFixed(1)}%`; } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } -function SummaryCard({ - title, - description, - href, - items, - status, - error, +function getAlertVariant( + alert: DashboardAlert, +): 'success' | 'warning' | 'danger' | 'info' { + if (alert.status === 'resolved') { + return 'success'; + } + if (alert.severity === 'critical') { + return 'danger'; + } + if (alert.severity === 'warning') { + return 'warning'; + } + return 'info'; +} + +function OverviewMetric({ + label, + value, + hint, }: { - title: string; - description: string; - href: string; - items: ReadonlyArray<{ label: string; value: string | number }>; - status: 'pending' | 'success' | 'error'; - error?: unknown; + label: string; + value: string | number; + hint?: string; }) { return ( - - 进入模块 - - } - > - {status === 'pending' ? ( - - ) : status === 'error' ? ( - - ) : ( -
- {items.map((item) => ( -
-

- {item.label} -

-

- {item.value} -

-
- ))} -
- )} -
+
+

+ {label} +

+

+ {value} +

+ {hint ? ( +

+ {hint} +

+ ) : null} +
); } -export function DashboardOverview() { - 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; - +function NodeHealthRow({ node }: { node: DashboardNodeHealth }) { return ( -
-
- {dashboardCards.map((card) => ( - - ))} +
+
+
+

+ {node.name} +

+

+ {node.node_id} +

+
+
+ + +
+
+ +
+
+

+ CPU +

+

+ {formatPercent(node.cpu_usage_percent)} +

+
+
+

+ 内存 +

+

+ {formatPercent(node.memory_usage_percent)} +

+
+
+

+ 最近窗口请求 +

+

+ {node.request_count} +

+
+
+

+ 活动异常 +

+

+ {node.active_event_count} +

+
+
+ +
+

当前版本:{node.current_version || '未应用'}

+

+ 最近心跳: + {node.last_seen_at + ? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : ' 暂无'} +

+
+
+ ); +} + +export function DashboardOverview() { + const overviewQuery = useQuery({ + queryKey: ['dashboard', 'overview'], + queryFn: getDashboardOverview, + refetchInterval: 10000, + }); + + if (overviewQuery.isLoading) { + return ; + } + + if (overviewQuery.isError) { + return ( + + ); + } + + const overview = overviewQuery.data; + if (!overview) { + return ( + + ); + } + + return ( +
+ + 生成时间:{formatDateTime(overview.generated_at)} +
+ } + > +
+ + + + + +
+ + +
+ + 进入节点页 + + } + > + {overview.nodes.length === 0 ? ( + + ) : ( +
+ {overview.nodes.slice(0, 8).map((node) => ( + + ))} +
+ )} +
+ +
+ +
+ + + +
+
+ + + {overview.active_alerts.length === 0 ? ( + + ) : ( +
+ {overview.active_alerts.map((alert) => ( +
+
+ +

+ {alert.node_name} +

+
+

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

+

+ {formatRelativeTime(alert.last_triggered_at)} ·{' '} + {formatDateTime(alert.last_triggered_at)} +

+
+ ))} +
+ )} +
+
); diff --git a/atsf_server/web/features/dashboard/types.ts b/atsf_server/web/features/dashboard/types.ts new file mode 100644 index 00000000..4378c799 --- /dev/null +++ b/atsf_server/web/features/dashboard/types.ts @@ -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[]; +}