diff --git a/atsf_agent/internal/agent/runner.go b/atsf_agent/internal/agent/runner.go index 65a24e89..6ef78e7c 100644 --- a/atsf_agent/internal/agent/runner.go +++ b/atsf_agent/internal/agent/runner.go @@ -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, } } diff --git a/atsf_agent/internal/agent/runner_test.go b/atsf_agent/internal/agent/runner_test.go index c572369c..5dec5516 100644 --- a/atsf_agent/internal/agent/runner_test.go +++ b/atsf_agent/internal/agent/runner_test.go @@ -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) { diff --git a/atsf_agent/internal/nginx/manager.go b/atsf_agent/internal/nginx/manager.go index f4e54263..d2bdd41b 100644 --- a/atsf_agent/internal/nginx/manager.go +++ b/atsf_agent/internal/nginx/manager.go @@ -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[:]) diff --git a/atsf_agent/internal/nginx/manager_test.go b/atsf_agent/internal/nginx/manager_test.go index c1fb25f4..0dc3fa8a 100644 --- a/atsf_agent/internal/nginx/manager_test.go +++ b/atsf_agent/internal/nginx/manager_test.go @@ -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, ) diff --git a/atsf_agent/internal/observability/traffic.go b/atsf_agent/internal/observability/traffic.go new file mode 100644 index 00000000..49dfd0eb --- /dev/null +++ b/atsf_agent/internal/observability/traffic.go @@ -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) +} diff --git a/atsf_agent/internal/observability/traffic_test.go b/atsf_agent/internal/observability/traffic_test.go new file mode 100644 index 00000000..8f7b4987 --- /dev/null +++ b/atsf_agent/internal/observability/traffic_test.go @@ -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) + } +} diff --git a/atsf_agent/internal/state/state.go b/atsf_agent/internal/state/state.go index 7fc60254..55e5d3a6 100644 --- a/atsf_agent/internal/state/state.go +++ b/atsf_agent/internal/state/state.go @@ -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 { diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index 0eacdf43..3f7d7d65 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -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; diff --git a/atsf_server/model/node_health_event.go b/atsf_server/model/node_health_event.go index fade3d32..082006a3 100644 --- a/atsf_server/model/node_health_event.go +++ b/atsf_server/model/node_health_event.go @@ -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 +} diff --git a/atsf_server/model/node_metric_snapshot.go b/atsf_server/model/node_metric_snapshot.go index 766ddefe..d106c0ad 100644 --- a/atsf_server/model/node_metric_snapshot.go +++ b/atsf_server/model/node_metric_snapshot.go @@ -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 +} diff --git a/atsf_server/model/node_request_report.go b/atsf_server/model/node_request_report.go index f475bc6a..aa79e156 100644 --- a/atsf_server/model/node_request_report.go +++ b/atsf_server/model/node_request_report.go @@ -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 +} diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go index 5addc492..66aa066e 100644 --- a/atsf_server/service/config_version.go +++ b/atsf_server/service/config_version.go @@ -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), diff --git a/atsf_server/service/dashboard.go b/atsf_server/service/dashboard.go index d29d7904..12777a17 100644 --- a/atsf_server/service/dashboard.go +++ b/atsf_server/service/dashboard.go @@ -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: diff --git a/atsf_server/service/https_phase1_test.go b/atsf_server/service/https_phase1_test.go index cbfcf7bc..1e0c788d 100644 --- a/atsf_server/service/https_phase1_test.go +++ b/atsf_server/service/https_phase1_test.go @@ -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) { diff --git a/atsf_server/service/node_observability.go b/atsf_server/service/node_observability.go index f17f4d3f..b0ce22e9 100644 --- a/atsf_server/service/node_observability.go +++ b/atsf_server/service/node_observability.go @@ -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 } diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index a550f93e..e5ae6057 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -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]) + } } diff --git a/atsf_server/service/observability_trends.go b/atsf_server/service/observability_trends.go new file mode 100644 index 00000000..0ea3811d --- /dev/null +++ b/atsf_server/service/observability_trends.go @@ -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 +} diff --git a/atsf_server/web/components/data/trend-chart.tsx b/atsf_server/web/components/data/trend-chart.tsx new file mode 100644 index 00000000..1df1b157 --- /dev/null +++ b/atsf_server/web/components/data/trend-chart.tsx @@ -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 ( +
+ 暂无趋势数据 +
+ ); + } + + const option = useMemo(() => { + 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 ( +
+
+ {series.map((item) => { + const latestValue = item.values[item.values.length - 1] ?? 0; + const formatter = item.valueFormatter ?? defaultFormatter; + return ( +
+
+ +

+ {item.label} +

+
+

+ {formatter(latestValue)} +

+
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 8b57a6b5..83ad8b28 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -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() { +
+ + + 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, + ), + }, + ]} + /> + + + + + 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, + }, + ]} + /> + +
+
+
+ + + 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, + ) ?? [], + }, + ]} + /> + + + + + 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, + }, + ]} + /> + +
+
diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts index c62e57ea..9d3915cc 100644 --- a/atsf_server/web/features/nodes/types.ts +++ b/atsf_server/web/features/nodes/types.ts @@ -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; } diff --git a/atsf_server/web/package.json b/atsf_server/web/package.json index b737a71c..5e752d74 100644 --- a/atsf_server/web/package.json +++ b/atsf_server/web/package.json @@ -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", diff --git a/atsf_server/web/pnpm-lock.yaml b/atsf_server/web/pnpm-lock.yaml index a8f40b60..564bd6f5 100644 --- a/atsf_server/web/pnpm-lock.yaml +++ b/atsf_server/web/pnpm-lock.yaml @@ -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 diff --git a/atsf_server/web/tests/unit/dashboard-overview.test.tsx b/atsf_server/web/tests/unit/dashboard-overview.test.tsx index ea9cee9f..d8d7189d 100644 --- a/atsf_server/web/tests/unit/dashboard-overview.test.tsx +++ b/atsf_server/web/tests/unit/dashboard-overview.test.tsx @@ -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: () =>
, +})); + 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', () => { , ); - 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(); }); });