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>
139 lines
3.1 KiB
Go
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
|
|
}
|