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
+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
}