mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06: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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user