feat: add 24-hour traffic and capacity trend charts to dashboard and node detail pages

- Implemented TrendChart component for visualizing traffic and capacity trends.
- Enhanced DashboardOverview and NodeDetailPage components to display 24-hour request and capacity trends.
- Updated types to include traffic and capacity trend data structures.
- Created observability trends service to aggregate traffic and capacity data.
- Added tests for traffic report generation and trend data handling.
- Introduced new dependencies for charting (echarts and echarts-for-react).
This commit is contained in:
ryan
2026-03-14 11:48:05 +08:00
parent e94132a7d9
commit 16bb9e5879
25 changed files with 1063 additions and 109 deletions
+2
View File
@@ -313,6 +313,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
}
profile := observability.BuildProfile(r.Config, r.StateStore)
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore)
healthEvents := observability.BuildHealthEvents(snapshot)
return protocol.NodePayload{
NodeID: nodeID,
@@ -326,6 +327,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
OpenrestyMessage: snapshot.OpenrestyMessage,
Profile: profile,
Snapshot: metricSnapshot,
TrafficReport: trafficReport,
HealthEvents: healthEvents,
}
}
+20 -2
View File
@@ -282,7 +282,8 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
}
func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
tempDir := t.TempDir()
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
if err := stateStore.Save(&state.Snapshot{
NodeID: "node-observe",
CurrentVersion: "20260314-001",
@@ -299,11 +300,22 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
NodeIP: "10.0.0.51",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
DataDir: t.TempDir(),
DataDir: tempDir,
RouteConfigPath: filepath.Join(tempDir, "conf.d", "atsflare_routes.conf"),
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
}
if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil {
t.Fatalf("failed to prepare route config dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
[]byte("{\"ts\":\"2026-03-14T10:00:00Z\",\"host\":\"edge.example.com\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
0o644,
); err != nil {
t.Fatalf("failed to prepare access log: %v", err)
}
firstPayload := runner.nodePayload("node-observe")
if firstPayload.Profile == nil {
@@ -312,6 +324,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
if firstPayload.Snapshot == nil {
t.Fatal("expected first heartbeat payload to include metric snapshot")
}
if firstPayload.TrafficReport == nil || firstPayload.TrafficReport.RequestCount != 1 {
t.Fatalf("expected first heartbeat payload to include traffic report, got %+v", firstPayload.TrafficReport)
}
if len(firstPayload.HealthEvents) != 2 {
t.Fatalf("expected health events for openresty and sync error, got %+v", firstPayload.HealthEvents)
}
@@ -323,6 +338,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
if secondPayload.Snapshot == nil {
t.Fatal("expected metric snapshot to continue reporting on subsequent heartbeat")
}
if secondPayload.TrafficReport != nil {
t.Fatalf("expected unchanged traffic window to be omitted on subsequent heartbeat, got %+v", secondPayload.TrafficReport)
}
}
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
+20 -4
View File
@@ -19,8 +19,10 @@ import (
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
const AccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/atsflare_access.log"
const dockerRuntimeCommand = "openresty"
@@ -285,6 +287,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
if includePath := m.routeConfigIncludePath(); includePath != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
}
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
}
normalizedRoute := string(data)
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
@@ -609,11 +614,14 @@ func (m *Manager) renderRouteConfig(content string) string {
}
func (m *Manager) renderMainConfig(content string) string {
includePath := m.routeConfigIncludePath()
if includePath == "" {
return content
rendered := content
if includePath := m.routeConfigIncludePath(); includePath != "" {
rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath)
}
return strings.ReplaceAll(content, RouteConfigPlaceholder, includePath)
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
}
return rendered
}
func (m *Manager) routeConfigIncludePath() string {
@@ -623,6 +631,14 @@ func (m *Manager) routeConfigIncludePath() string {
return strings.TrimSpace(m.RouteConfigPath)
}
func (m *Manager) accessLogRuntimePath() string {
includePath := m.routeConfigIncludePath()
if strings.TrimSpace(includePath) == "" {
return ""
}
return filepath.ToSlash(filepath.Join(filepath.Dir(includePath), "atsflare_access.log"))
}
func checksum(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
+8 -6
View File
@@ -337,7 +337,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
err := manager.Apply(
context.Background(),
"include __ATSF_ROUTE_CONFIG__;\n",
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
)
@@ -349,7 +349,8 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
if string(mainData) != "include "+routePath+";\n" {
expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "atsflare_access.log") + " atsflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config: %s", string(mainData))
}
@@ -366,7 +367,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"include __ATSF_ROUTE_CONFIG__;\n",
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
)
@@ -388,7 +389,7 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
Executor: &fakeExecutor{},
}
if err := manager.Apply(context.Background(), "include __ATSF_ROUTE_CONFIG__;\n", "server { listen 80; }\n", nil); err != nil {
if err := manager.Apply(context.Background(), "include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n", "server { listen 80; }\n", nil); err != nil {
t.Fatalf("Apply failed: %v", err)
}
@@ -396,7 +397,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
if string(mainData) != "include "+DockerRouteConfigPath+";\n" {
expectedMain := "include " + DockerRouteConfigPath + ";\naccess_log " + DockerAccessLogPath + " atsflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config include path: %s", string(mainData))
}
@@ -405,7 +407,7 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"include __ATSF_ROUTE_CONFIG__;\n",
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
"server { listen 80; }\n",
nil,
)
@@ -0,0 +1,202 @@
package observability
import (
"atsflare-agent/internal/config"
"atsflare-agent/internal/protocol"
"atsflare-agent/internal/state"
"bufio"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
type accessLogRecord struct {
Timestamp string `json:"ts"`
Host string `json:"host"`
RemoteAddr string `json:"remote_addr"`
Status int `json:"status"`
}
type trafficAggregate struct {
windowStartedAt time.Time
windowEndedAt time.Time
requestCount int64
errorCount int64
statusCodes map[string]int64
topDomains map[string]int64
visitors map[string]struct{}
}
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store) *protocol.NodeTrafficReport {
if cfg == nil || stateStore == nil {
return nil
}
snapshot, err := stateStore.Load()
if err != nil {
return nil
}
logPath := managedAccessLogPath(cfg)
file, err := os.Open(logPath)
if err != nil {
if os.IsNotExist(err) {
if snapshot.AccessLogOffset != 0 {
snapshot.AccessLogOffset = 0
_ = stateStore.Save(snapshot)
}
return nil
}
return nil
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil
}
offset := snapshot.AccessLogOffset
if offset < 0 || offset > info.Size() {
offset = 0
}
if _, err = file.Seek(offset, io.SeekStart); err != nil {
return nil
}
reader := bufio.NewReader(file)
currentOffset := offset
aggregate := newTrafficAggregate()
for {
line, readErr := reader.ReadBytes('\n')
if len(line) > 0 {
currentOffset += int64(len(line))
aggregate.consume(line)
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return nil
}
}
snapshot.AccessLogOffset = currentOffset
_ = stateStore.Save(snapshot)
return aggregate.report()
}
func managedAccessLogPath(cfg *config.Config) string {
if cfg == nil || strings.TrimSpace(cfg.RouteConfigPath) == "" {
return ""
}
return filepath.Join(filepath.Dir(cfg.RouteConfigPath), "atsflare_access.log")
}
func newTrafficAggregate() *trafficAggregate {
return &trafficAggregate{
statusCodes: make(map[string]int64),
topDomains: make(map[string]int64),
visitors: make(map[string]struct{}),
}
}
func (aggregate *trafficAggregate) consume(line []byte) {
trimmed := strings.TrimSpace(string(line))
if trimmed == "" {
return
}
var record accessLogRecord
if err := json.Unmarshal([]byte(trimmed), &record); err != nil {
return
}
timestamp, err := parseAccessLogTime(record.Timestamp)
if err != nil {
return
}
if aggregate.windowStartedAt.IsZero() || timestamp.Before(aggregate.windowStartedAt) {
aggregate.windowStartedAt = timestamp
}
if aggregate.windowEndedAt.IsZero() || timestamp.After(aggregate.windowEndedAt) {
aggregate.windowEndedAt = timestamp
}
aggregate.requestCount++
if record.Status >= 500 {
aggregate.errorCount++
}
if record.Status > 0 {
aggregate.statusCodes[strconv.Itoa(record.Status)]++
}
if host := strings.TrimSpace(record.Host); host != "" {
aggregate.topDomains[host]++
}
if remoteAddr := strings.TrimSpace(record.RemoteAddr); remoteAddr != "" {
aggregate.visitors[remoteAddr] = struct{}{}
}
}
func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport {
if aggregate.requestCount == 0 || aggregate.windowStartedAt.IsZero() || aggregate.windowEndedAt.IsZero() {
return nil
}
return &protocol.NodeTrafficReport{
WindowStartedAtUnix: aggregate.windowStartedAt.Unix(),
WindowEndedAtUnix: aggregate.windowEndedAt.Unix(),
RequestCount: aggregate.requestCount,
ErrorCount: aggregate.errorCount,
UniqueVisitorCount: int64(len(aggregate.visitors)),
StatusCodes: cloneTrafficCounts(aggregate.statusCodes, 0),
TopDomains: topCounts(aggregate.topDomains, 8),
SourceCountries: map[string]int64{},
}
}
func parseAccessLogTime(value string) (time.Time, error) {
return time.Parse(time.RFC3339, strings.TrimSpace(value))
}
func cloneTrafficCounts(values map[string]int64, limit int) map[string]int64 {
if len(values) == 0 {
return map[string]int64{}
}
items := make([]trafficCountItem, 0, len(values))
for key, value := range values {
items = append(items, trafficCountItem{key: key, value: value})
}
sort.Slice(items, func(i int, j int) bool {
if items[i].value == items[j].value {
return items[i].key < items[j].key
}
return items[i].value > items[j].value
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
result := make(map[string]int64, len(items))
for _, item := range items {
result[item.key] = item.value
}
return result
}
type trafficCountItem struct {
key string
value int64
}
func topCounts(values map[string]int64, limit int) map[string]int64 {
return cloneTrafficCounts(values, limit)
}
@@ -0,0 +1,77 @@
package observability
import (
"os"
"path/filepath"
"testing"
"atsflare-agent/internal/config"
"atsflare-agent/internal/state"
)
func TestBuildTrafficReportAggregatesManagedAccessLog(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
content := []byte(
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n" +
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.2\",\"status\":503}\n" +
"{\"ts\":\"2026-03-14T08:00:08Z\",\"host\":\"api.example.com\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
if report == nil {
t.Fatal("expected traffic report")
}
if report.RequestCount != 3 || report.ErrorCount != 1 || report.UniqueVisitorCount != 2 {
t.Fatalf("unexpected traffic report counters: %+v", report)
}
if report.StatusCodes["200"] != 2 || report.StatusCodes["503"] != 1 {
t.Fatalf("unexpected status codes: %+v", report.StatusCodes)
}
if report.TopDomains["app.example.com"] != 2 || report.TopDomains["api.example.com"] != 1 {
t.Fatalf("unexpected top domains: %+v", report.TopDomains)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if snapshot.AccessLogOffset != int64(len(content)) {
t.Fatalf("unexpected access log offset: %d", snapshot.AccessLogOffset)
}
secondReport := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
if secondReport != nil {
t.Fatalf("expected no report without appended lines, got %+v", secondReport)
}
}
func TestBuildTrafficReportResetsOffsetAfterTruncate(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
if err := os.WriteFile(logPath, []byte("{\"ts\":\"2026-03-14T09:00:00Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.3\",\"status\":200}\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
if err := stateStore.Save(&state.Snapshot{AccessLogOffset: 4096}); err != nil {
t.Fatalf("Save failed: %v", err)
}
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
if report == nil || report.RequestCount != 1 {
t.Fatalf("expected one request after truncate reset, got %+v", report)
}
}
+1
View File
@@ -20,6 +20,7 @@ type Snapshot struct {
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
AccessLogOffset int64 `json:"access_log_offset"`
}
type Store struct {
+2
View File
@@ -99,6 +99,8 @@ events {
http {
include mime.types;
default_type application/octet-stream;
log_format atsflare_json escape=json '{"ts":"$time_iso8601","host":"$host","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
access_log {{OpenRestyAccessLogPath}} atsflare_json;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
+5
View File
@@ -35,3 +35,8 @@ func ListNodeHealthEvents(nodeID string, activeOnly bool, limit int) (events []*
err = query.Find(&events).Error
return events, err
}
func ListActiveNodeHealthEvents() (events []*NodeHealthEvent, err error) {
err = DB.Where("status = ?", "active").Order("last_triggered_at desc").Find(&events).Error
return events, err
}
@@ -37,3 +37,12 @@ func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapsho
err = query.Find(&snapshots).Error
return snapshots, err
}
func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) {
query := DB.Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
}
err = query.Find(&snapshots).Error
return snapshots, err
}
+9
View File
@@ -32,3 +32,12 @@ func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports
err = query.Find(&reports).Error
return reports, err
}
func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) {
query := DB.Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
}
err = query.Find(&reports).Error
return reports, err
}
+3
View File
@@ -117,12 +117,14 @@ type configBundle struct {
const (
nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
nginxAccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
)
var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyWorkerProcesses}}",
"{{OpenRestyWorkerConnections}}",
"{{OpenRestyWorkerRlimitNofile}}",
"{{OpenRestyAccessLogPath}}",
"{{OpenRestyEventsUseDirective}}",
"{{OpenRestyEventsMultiAcceptDirective}}",
"{{OpenRestyKeepaliveTimeout}}",
@@ -638,6 +640,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
"{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder,
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
"{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout),
+71 -25
View File
@@ -15,6 +15,7 @@ type DashboardOverviewView struct {
Traffic DashboardTraffic `json:"traffic"`
Capacity DashboardCapacity `json:"capacity"`
Config DashboardConfig `json:"config"`
Trends DashboardTrends `json:"trends"`
Nodes []DashboardNodeHealth `json:"nodes"`
ActiveAlerts []DashboardAlert `json:"active_alerts"`
}
@@ -51,6 +52,11 @@ type DashboardConfig struct {
PendingNodes int `json:"pending_nodes"`
}
type DashboardTrends struct {
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
}
type DashboardNodeHealth struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
@@ -94,13 +100,33 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
return nil, versionErr
}
snapshots, err := model.ListMetricSnapshotsSince(since)
if err != nil {
return nil, err
}
reports, err := model.ListRequestReportsSince(since)
if err != nil {
return nil, err
}
activeEvents, err := model.ListActiveNodeHealthEvents()
if err != nil {
return nil, err
}
view := &DashboardOverviewView{
GeneratedAt: now,
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
Trends: DashboardTrends{
Traffic24h: buildTrafficTrendPoints(now, reports),
Capacity24h: buildCapacityTrendPoints(now, snapshots),
},
}
var cpuNodeCount int
var memoryNodeCount int
latestSnapshots := latestMetricSnapshotsByNode(snapshots)
latestTrafficReports := latestTrafficReportsByNode(reports)
activeEventsByNode := activeHealthEventsByNode(activeEvents)
for _, node := range nodes {
computedStatus := computeNodeStatus(node)
@@ -122,12 +148,9 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
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
}
latestSnapshot := latestSnapshots[node.NodeID]
latestTraffic := latestTrafficReports[node.NodeID]
nodeActiveEvents := activeEventsByNode[node.NodeID]
nodeHealth := DashboardNodeHealth{
ID: node.ID,
@@ -137,10 +160,10 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
OpenrestyStatus: node.OpenrestyStatus,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
ActiveEventCount: len(activeEvents),
ActiveEventCount: len(nodeActiveEvents),
}
for _, event := range activeEvents {
for _, event := range nodeActiveEvents {
view.ActiveAlerts = append(view.ActiveAlerts, DashboardAlert{
NodeID: node.NodeID,
NodeName: node.Name,
@@ -188,7 +211,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view.Traffic.ReportedNodes++
}
view.Summary.ActiveAlerts += len(activeEvents)
view.Summary.ActiveAlerts += len(nodeActiveEvents)
view.Nodes = append(view.Nodes, nodeHealth)
}
@@ -225,22 +248,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
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
@@ -248,6 +255,45 @@ func percentage(used int64, total int64) float64 {
return (float64(used) / float64(total)) * 100
}
func latestMetricSnapshotsByNode(snapshots []*model.NodeMetricSnapshot) map[string]*model.NodeMetricSnapshot {
result := make(map[string]*model.NodeMetricSnapshot, len(snapshots))
for _, snapshot := range snapshots {
if snapshot == nil || snapshot.NodeID == "" {
continue
}
if existing, ok := result[snapshot.NodeID]; ok && !snapshot.CapturedAt.After(existing.CapturedAt) {
continue
}
result[snapshot.NodeID] = snapshot
}
return result
}
func latestTrafficReportsByNode(reports []*model.NodeRequestReport) map[string]*model.NodeRequestReport {
result := make(map[string]*model.NodeRequestReport, len(reports))
for _, report := range reports {
if report == nil || report.NodeID == "" {
continue
}
if existing, ok := result[report.NodeID]; ok && !report.WindowEndedAt.After(existing.WindowEndedAt) {
continue
}
result[report.NodeID] = report
}
return result
}
func activeHealthEventsByNode(events []*model.NodeHealthEvent) map[string][]*model.NodeHealthEvent {
result := make(map[string][]*model.NodeHealthEvent)
for _, event := range events {
if event == nil || event.NodeID == "" {
continue
}
result[event.NodeID] = append(result[event.NodeID], event)
}
return result
}
func severityWeight(severity string) int {
switch severity {
case NodeHealthSeverityCritical:
+15
View File
@@ -54,6 +54,9 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
t.Fatal("expected main config to include managed route config placeholder")
}
if !strings.Contains(result.Version.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") {
t.Fatal("expected main config to include managed access log placeholder")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected rendered config to include https server block")
}
@@ -279,6 +282,9 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
t.Fatal("expected preview main config to preserve managed route include")
}
if !strings.Contains(preview.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") {
t.Fatal("expected preview main config to preserve managed access log placeholder")
}
invalidTemplate := strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
@@ -288,6 +294,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed route placeholder to fail validation")
}
invalidTemplate = strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
"{{OpenRestyAccessLogPath}}",
"",
)
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed access log placeholder to fail validation")
}
}
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
+20 -1
View File
@@ -25,16 +25,23 @@ type NodeObservabilityView struct {
MetricSnapshots []*model.NodeMetricSnapshot `json:"metric_snapshots"`
TrafficReports []*model.NodeRequestReport `json:"traffic_reports"`
HealthEvents []*model.NodeHealthEvent `json:"health_events"`
Trends NodeObservabilityTrends `json:"trends"`
}
type NodeObservabilityTrends struct {
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
}
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
now := time.Now()
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
limit := normalizeObservabilityLimit(query.Limit)
since := time.Now().Add(-normalizeObservabilityWindow(query.Hours))
since := now.Add(-normalizeObservabilityWindow(query.Hours))
profile, err := model.GetNodeSystemProfile(node.NodeID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -52,6 +59,14 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
if err != nil {
return nil, err
}
trendSnapshots, err := model.ListNodeMetricSnapshots(node.NodeID, now.Add(-24*time.Hour), 0)
if err != nil {
return nil, err
}
trendReports, err := model.ListNodeRequestReports(node.NodeID, now.Add(-24*time.Hour), 0)
if err != nil {
return nil, err
}
events, err := model.ListNodeHealthEvents(node.NodeID, false, limit)
if err != nil {
return nil, err
@@ -63,6 +78,10 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
MetricSnapshots: snapshots,
TrafficReports: reports,
HealthEvents: events,
Trends: NodeObservabilityTrends{
Traffic24h: buildTrafficTrendPoints(now, trendReports),
Capacity24h: buildCapacityTrendPoints(now, trendSnapshots),
},
}, nil
}
+15
View File
@@ -564,6 +564,12 @@ func TestGetNodeObservability(t *testing.T) {
if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" {
t.Fatalf("unexpected health events: %+v", view.HealthEvents)
}
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
t.Fatalf("expected 24-point trends, got %+v", view.Trends)
}
if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 123 {
t.Fatalf("unexpected traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1])
}
}
func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
@@ -589,6 +595,9 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
if view.Profile != nil {
t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile)
}
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
t.Fatalf("expected empty 24-point trends, got %+v", view.Trends)
}
}
func TestGetDashboardOverview(t *testing.T) {
@@ -715,4 +724,10 @@ func TestGetDashboardOverview(t *testing.T) {
if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts)
}
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
t.Fatalf("expected 24-point dashboard trends, got %+v", view.Trends)
}
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])
}
}
+106
View File
@@ -0,0 +1,106 @@
package service
import (
"atsflare/model"
"time"
)
const observabilityTrendBuckets = 24
type TrafficTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
ErrorCount int64 `json:"error_count"`
UniqueVisitorCount int64 `json:"unique_visitor_count"`
}
type CapacityTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"`
AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"`
ReportedNodes int `json:"reported_nodes"`
}
type capacityTrendAccumulator struct {
cpuSum float64
cpuCount int
memSum float64
memCount int
nodes map[string]struct{}
}
func buildTrafficTrendPoints(now time.Time, reports []*model.NodeRequestReport) []TrafficTrendPoint {
start := trendWindowStart(now)
points := make([]TrafficTrendPoint, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
}
for _, report := range reports {
index, ok := trendBucketIndex(report.WindowEndedAt, start)
if !ok {
continue
}
points[index].RequestCount += report.RequestCount
points[index].ErrorCount += report.ErrorCount
points[index].UniqueVisitorCount += report.UniqueVisitorCount
}
return points
}
func buildCapacityTrendPoints(now time.Time, snapshots []*model.NodeMetricSnapshot) []CapacityTrendPoint {
start := trendWindowStart(now)
points := make([]CapacityTrendPoint, observabilityTrendBuckets)
accumulators := make([]capacityTrendAccumulator, observabilityTrendBuckets)
for index := range points {
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
accumulators[index].nodes = make(map[string]struct{})
}
for _, snapshot := range snapshots {
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
if !ok {
continue
}
if snapshot.CPUUsagePercent > 0 {
accumulators[index].cpuSum += snapshot.CPUUsagePercent
accumulators[index].cpuCount++
}
if memoryUsage := percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes); memoryUsage > 0 {
accumulators[index].memSum += memoryUsage
accumulators[index].memCount++
}
if snapshot.NodeID != "" {
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
}
}
for index := range points {
if accumulators[index].cpuCount > 0 {
points[index].AverageCPUUsagePercent = accumulators[index].cpuSum / float64(accumulators[index].cpuCount)
}
if accumulators[index].memCount > 0 {
points[index].AverageMemoryUsagePercent = accumulators[index].memSum / float64(accumulators[index].memCount)
}
points[index].ReportedNodes = len(accumulators[index].nodes)
}
return points
}
func trendWindowStart(now time.Time) time.Time {
return now.Truncate(time.Hour).Add(-(observabilityTrendBuckets - 1) * time.Hour)
}
func trendBucketIndex(timestamp time.Time, start time.Time) (int, bool) {
if timestamp.Before(start) {
return 0, false
}
delta := timestamp.Sub(start)
index := int(delta / time.Hour)
if index < 0 || index >= observabilityTrendBuckets {
return 0, false
}
return index, true
}
@@ -0,0 +1,161 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
type TrendChartSeries = {
label: string;
color: string;
fillColor?: string;
values: number[];
variant?: 'line' | 'area';
valueFormatter?: (value: number) => string;
};
type TrendChartProps = {
labels: string[];
series: TrendChartSeries[];
height?: number;
};
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
if (labels.length === 0 || series.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)]">
暂无趋势数据
</div>
);
}
const option = useMemo<EChartsOption>(() => {
const maxValue =
Math.max(
1,
...series.flatMap((item) => item.values.map((value) => value || 0)),
) * 1.1;
return {
animationDuration: 500,
animationEasing: 'cubicOut',
grid: {
left: 16,
right: 16,
top: 20,
bottom: 20,
containLabel: true,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
},
legend: {
show: false,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.24)',
},
},
axisTick: {
show: false,
},
axisLabel: {
color: '#94a3b8',
margin: 14,
},
},
yAxis: {
type: 'value',
min: 0,
max: maxValue,
splitNumber: 4,
axisLabel: {
color: '#94a3b8',
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
series: series.map((item) => ({
name: item.label,
type: 'line',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: item.color,
width: 3,
},
itemStyle: {
color: item.color,
},
areaStyle:
item.variant === 'area'
? {
color: item.fillColor ?? `${item.color}33`,
}
: undefined,
emphasis: {
focus: 'series',
scale: true,
},
data: item.values,
})),
};
}, [labels, series]);
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
{series.map((item) => {
const latestValue = item.values[item.values.length - 1] ?? 0;
const formatter = item.valueFormatter ?? defaultFormatter;
return (
<div
key={item.label}
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color }}
/>
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
</div>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{formatter(latestValue)}
</p>
</div>
);
})}
</div>
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height, width: '100%' }}
/>
</div>
</div>
);
}
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
@@ -32,6 +33,14 @@ function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function getAlertVariant(
alert: DashboardAlert,
): 'success' | 'warning' | 'danger' | 'info' {
@@ -215,6 +224,68 @@ export function DashboardOverview() {
</div>
</AppCard>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
description="观察整体请求量和错误量是否出现异常抬升。"
>
<TrendChart
labels={overview.trends.traffic_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: overview.trends.traffic_24h.map(
(point) => point.request_count,
),
},
{
label: '错误量',
color: '#ef4444',
values: overview.trends.traffic_24h.map(
(point) => point.error_count,
),
},
]}
/>
</AppCard>
<AppCard
title="24 小时容量趋势"
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
>
<TrendChart
labels={overview.trends.capacity_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
series={[
{
label: '平均 CPU',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: overview.trends.capacity_24h.map(
(point) => point.average_cpu_usage_percent,
),
valueFormatter: formatPercent,
},
{
label: '平均内存',
color: '#2563eb',
values: overview.trends.capacity_24h.map(
(point) => point.average_memory_usage_percent,
),
valueFormatter: formatPercent,
},
]}
/>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title="节点健康列表"
@@ -30,6 +30,25 @@ export interface DashboardConfig {
pending_nodes: number;
}
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface CapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface DashboardTrends {
traffic_24h: TrafficTrendPoint[];
capacity_24h: CapacityTrendPoint[];
}
export interface DashboardNodeHealth {
id: number;
node_id: string;
@@ -63,6 +82,7 @@ export interface DashboardOverview {
traffic: DashboardTraffic;
capacity: DashboardCapacity;
config: DashboardConfig;
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 { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
@@ -143,6 +144,14 @@ function formatUptime(seconds?: number | null) {
return `${minutes} 分钟`;
}
function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function getHealthEventVariant(
event: NodeObservability['health_events'][number],
): 'success' | 'warning' | 'danger' | 'info' {
@@ -829,6 +838,76 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
description="按小时聚合该节点的请求量和错误量,判断是否存在突发流量或持续异常。"
>
<TrendChart
labels={
observability?.trends.traffic_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
) ?? []
}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values:
observability?.trends.traffic_24h.map(
(point) => point.request_count,
) ?? [],
},
{
label: '错误量',
color: '#ef4444',
values:
observability?.trends.traffic_24h.map(
(point) => point.error_count,
) ?? [],
},
]}
/>
</AppCard>
<AppCard
title="24 小时容量趋势"
description="观察该节点 CPU 与内存使用率在 24 小时内的变化,辅助扩容和排障。"
>
<TrendChart
labels={
observability?.trends.capacity_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
) ?? []
}
series={[
{
label: '平均 CPU',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values:
observability?.trends.capacity_24h.map(
(point) => point.average_cpu_usage_percent,
) ?? [],
valueFormatter: formatPercent,
},
{
label: '平均内存',
color: '#2563eb',
values:
observability?.trends.capacity_24h.map(
(point) => point.average_memory_usage_percent,
) ?? [],
valueFormatter: formatPercent,
},
]}
/>
</AppCard>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<AppCard title="更新模式">
<div className="space-y-3">
+20
View File
@@ -99,6 +99,25 @@ export interface NodeTrafficReport {
source_countries_json: string;
}
export interface NodeTrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface NodeCapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
@@ -116,4 +135,5 @@ export interface NodeObservability {
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
trends: NodeObservabilityTrends;
}
+2
View File
@@ -20,6 +20,8 @@
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.66.8",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"marked": "^17.0.4",
"next": "15.2.2",
"react": "19.0.0",
+44
View File
@@ -17,6 +17,12 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
echarts:
specifier: ^6.0.0
version: 6.0.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.0.0)(react@19.0.0)
marked:
specifier: ^17.0.4
version: 17.0.4
@@ -1421,6 +1427,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
@@ -2528,6 +2543,9 @@ packages:
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -2701,6 +2719,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2922,6 +2943,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
@@ -4131,6 +4155,18 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.0.0):
dependencies:
echarts: 6.0.0
fast-deep-equal: 3.1.3
react: 19.0.0
size-sensor: 1.0.3
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.307: {}
emoji-regex@9.2.2: {}
@@ -5421,6 +5457,8 @@ snapshots:
is-arrayish: 0.3.4
optional: true
size-sensor@1.0.3: {}
slash@3.0.0: {}
source-map-js@1.2.1: {}
@@ -5590,6 +5628,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
@@ -5853,6 +5893,10 @@ snapshots:
zod@3.25.76: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0
zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.0):
optionalDependencies:
'@types/react': 19.2.14
@@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
vi.mock('echarts-for-react', () => ({
default: () => <div data-testid="echarts-mock" />,
}));
describe('DashboardOverview', () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -15,80 +19,86 @@ describe('DashboardOverview', () => {
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/nodes/')) {
if (url.includes('/dashboard/overview')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, status: 'online', auto_update_enabled: true }],
}),
),
);
}
if (url.includes('/config-versions/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, version: '20260311-001', is_active: true, created_at: '2026-03-11T10:00:00Z' }],
}),
),
);
}
if (url.includes('/managed-domains/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, domain: '*.example.com', enabled: true, cert_id: 1 }],
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [
{
id: 1,
name: 'example',
not_after: '2026-04-01T00:00:00Z',
updated_at: '2026-03-10T10:00:00Z',
data: {
generated_at: '2026-03-14T08:00:00Z',
summary: {
total_nodes: 2,
online_nodes: 2,
offline_nodes: 0,
pending_nodes: 0,
unhealthy_nodes: 1,
active_alerts: 1,
lagging_nodes: 1,
},
],
}),
),
);
}
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }],
}),
),
);
}
if (url.includes('/user/search')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, role: 100, status: 1, username: 'root' }],
traffic: {
request_count: 900,
unique_visitors: 200,
error_count: 36,
estimated_qps: 15,
reported_nodes: 2,
},
capacity: {
average_cpu_usage_percent: 68.5,
average_memory_usage_percent: 71.8,
high_cpu_nodes: 1,
high_memory_nodes: 1,
high_storage_nodes: 1,
},
config: {
active_version: '20260314-001',
lagging_nodes: 1,
pending_nodes: 0,
},
trends: {
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
request_count: index * 10,
error_count: index,
unique_visitor_count: index * 3,
})),
capacity_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
average_cpu_usage_percent: index,
average_memory_usage_percent: index + 10,
reported_nodes: 2,
})),
},
nodes: [
{
id: 1,
node_id: 'node-a',
name: 'edge-a',
status: 'online',
openresty_status: 'healthy',
current_version: '20260314-001',
last_seen_at: '2026-03-14T08:00:00Z',
active_event_count: 0,
cpu_usage_percent: 45,
memory_usage_percent: 50,
storage_usage_percent: 60,
request_count: 600,
error_count: 6,
unique_visitor_count: 120,
},
],
active_alerts: [
{
node_id: 'node-b',
node_name: 'edge-b',
event_type: 'openresty_unhealthy',
severity: 'critical',
message: 'reload failed',
last_triggered_at: '2026-03-14T07:59:00Z',
status: 'active',
},
],
},
}),
),
);
@@ -112,8 +122,8 @@ describe('DashboardOverview', () => {
</QueryClientProvider>,
);
expect(screen.getByText('控制台仪表盘')).toBeInTheDocument();
expect(await screen.findByText('节点摘要')).toBeInTheDocument();
expect(await screen.findByText('用户概览')).toBeInTheDocument();
expect(await screen.findByText('系统运行总览')).toBeInTheDocument();
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
expect(await screen.findByText('节点健康列表')).toBeInTheDocument();
});
});