Files
OpenFlare/atsf_agent/internal/observability/traffic.go
T
ryan 16bb9e5879 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).
2026-03-14 11:48:05 +08:00

203 lines
4.7 KiB
Go

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