Caddyview/internal/parser/caddy.go
SW b25badb5c8 feat: add history views with bar chart and tab navigation
Tabs 1-4 switch between Live, 1 Day, 1 Week, 1 Month views.
History loads on demand via SSH by reading rotated log files
(access.log, access.log.1, access.log.2.gz, ...) filtered by
timestamp on the remote side using awk.

Bar chart shows requests per time bucket (1h/6h/24h depending
on window). Bottom panels show period stats, top hosts, top IPs,
and status codes — consistent across all views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:59:27 +02:00

139 lines
3.1 KiB
Go

package parser
import (
"encoding/json"
"strings"
"time"
)
// LogEntry represents one Caddy JSON access log line.
type LogEntry struct {
Timestamp float64 `json:"ts"`
Level string `json:"level"`
Logger string `json:"logger"`
Request struct {
Method string `json:"method"`
URI string `json:"uri"`
Host string `json:"host"`
Proto string `json:"proto"`
RemoteIP string `json:"remote_ip"`
} `json:"request"`
Duration float64 `json:"duration"`
Size int64 `json:"size"`
Status int `json:"status"`
}
func (e *LogEntry) Time() time.Time {
sec := int64(e.Timestamp)
nsec := int64((e.Timestamp - float64(sec)) * 1e9)
return time.Unix(sec, nsec)
}
// ParseLine parses a single Caddy JSON log line. Returns nil if not a request log.
func ParseLine(line string) *LogEntry {
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "{") {
return nil
}
var entry LogEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
return nil
}
if entry.Request.Host == "" {
return nil
}
return &entry
}
// ParseLines parses multiple lines and returns valid entries.
func ParseLines(lines []string) []*LogEntry {
entries := make([]*LogEntry, 0, len(lines))
for _, l := range lines {
if e := ParseLine(l); e != nil {
entries = append(entries, e)
}
}
return entries
}
// Stats holds aggregated metrics for a time window.
type Stats struct {
TotalRequests int64
TotalBytes int64
StatusCodes map[int]int64
TopHosts map[string]int64
TopIPs map[string]int64
AvgDurationMs float64
durationSum float64
durationN int64
}
func NewStats() *Stats {
return &Stats{
StatusCodes: make(map[int]int64),
TopHosts: make(map[string]int64),
TopIPs: make(map[string]int64),
}
}
func (s *Stats) Add(e *LogEntry) {
s.TotalRequests++
s.TotalBytes += e.Size
s.StatusCodes[e.Status]++
s.TopHosts[e.Request.Host]++
s.TopIPs[e.Request.RemoteIP]++
s.durationSum += e.Duration * 1000
s.durationN++
if s.durationN > 0 {
s.AvgDurationMs = s.durationSum / float64(s.durationN)
}
}
// BytesPerSec calculates transfer rate given a window duration in seconds.
func (s *Stats) BytesPerSec(windowSecs float64) float64 {
if windowSecs <= 0 {
return 0
}
return float64(s.TotalBytes) / windowSecs
}
// Bucket aggregates entries into fixed time buckets for the history view.
type Bucket struct {
Time time.Time
Requests int64
Bytes int64
}
// ComputeStats builds a Stats object from a slice of log entries.
func ComputeStats(entries []*LogEntry) *Stats {
s := NewStats()
for _, e := range entries {
s.Add(e)
}
return s
}
// Bucketize groups entries into buckets of the given duration.
func Bucketize(entries []*LogEntry, bucketSize time.Duration) []Bucket {
if len(entries) == 0 {
return nil
}
buckets := map[int64]*Bucket{}
for _, e := range entries {
t := e.Time().Truncate(bucketSize)
key := t.Unix()
if _, ok := buckets[key]; !ok {
buckets[key] = &Bucket{Time: t}
}
buckets[key].Requests++
buckets[key].Bytes += e.Size
}
result := make([]Bucket, 0, len(buckets))
for _, b := range buckets {
result = append(result, *b)
}
return result
}