mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
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:
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user