Caddyview/internal/parser/caddy.go
SW 7c6adbc17e fix: rework history aggregation, live streaming and visual refresh
History was effectively frozen: the find pattern 'access.log*' never
matched Caddy's native rotation naming (access-<ts>.log.gz), so rotated
files were invisible and the server-side cache - invalidated only by new
.gz files - never refreshed after the first load.

- match both Caddy native (access-*.log.gz) and logrotate (access.log.N)
  rotation naming
- cache aggregation per rotated file (immutable, scanned exactly once);
  always scan the current log fresh, so history never goes stale
- always bucket hourly on the server, re-bucket client-side so all tabs
  share the same caches
- cap IPs at top 50 per file so output stays small on busy servers
- aggregate request duration so history shows real avg response time
- tail -F instead of -f: Caddy rotates by rename, which silently killed
  the live stream at every rotation on high-traffic servers
- process live lines in batches instead of one Update cycle per line
- fetch CPU/MEM stats every 5s instead of every second
- stable sort in topN: equal counts no longer flicker and swap rows
- drop the 1 Month tab (Live / 1 Day / 1 Week remain)
- retro green-phosphor CRT look: double borders, ▓░ bars, amber accents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:30:38 +02:00

287 lines
7.1 KiB
Go

package parser
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"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 == "" || entry.Status == 0 {
return nil
}
return &entry
}
// -- Stats (used for live view and derived from AggregatedHistory for history) --
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)
}
}
func (s *Stats) BytesPerSec(windowSecs float64) float64 {
if windowSecs <= 0 {
return 0
}
return float64(s.TotalBytes) / windowSecs
}
// -- Bucket (time-series bar chart data) --------------------------------------
type Bucket struct {
Time time.Time
Requests int64
Bytes int64
}
// -- AggregatedHistory (server-side aggregation result) -----------------------
// AggregatedBucket is one time bucket returned by server-side awk aggregation.
type AggregatedBucket struct {
Time time.Time
Requests, Bytes int64
Status2xx, Status3xx, Status4xx, Status5xx int64
}
// AggregatedHistory holds the merged result of per-file server-side aggregations.
type AggregatedHistory struct {
Buckets []AggregatedBucket
TopHosts map[string]int64
TopIPs map[string]int64
TotalReqs int64
TotalBytes int64
DurSumSecs float64
}
// FilterSince drops buckets older than the window and recomputes totals from
// the remaining buckets, so the chart and the totals always agree. TopHosts and
// TopIPs come from whole rotated files and may slightly overcount at the oldest
// edge of the window — acceptable for a monitoring view.
func (h *AggregatedHistory) FilterSince(since int64) {
avgDur := 0.0
if h.TotalReqs > 0 {
avgDur = h.DurSumSecs / float64(h.TotalReqs)
}
kept := h.Buckets[:0]
var reqs, bytes int64
for _, b := range h.Buckets {
if b.Time.Unix() >= since {
kept = append(kept, b)
reqs += b.Requests
bytes += b.Bytes
}
}
h.Buckets = kept
h.TotalReqs = reqs
h.TotalBytes = bytes
h.DurSumSecs = avgDur * float64(reqs)
}
// ToStats converts aggregated history to a Stats object for use in the TUI panels.
func (h *AggregatedHistory) ToStats() *Stats {
s := NewStats()
s.TotalRequests = h.TotalReqs
s.TotalBytes = h.TotalBytes
s.TopHosts = h.TopHosts
s.TopIPs = h.TopIPs
if h.TotalReqs > 0 {
s.AvgDurationMs = h.DurSumSecs / float64(h.TotalReqs) * 1000
}
for _, b := range h.Buckets {
s.StatusCodes[200] += b.Status2xx
s.StatusCodes[301] += b.Status3xx
s.StatusCodes[404] += b.Status4xx
s.StatusCodes[500] += b.Status5xx
}
return s
}
// ToBuckets re-buckets the hourly server-side buckets into the granularity the
// chart wants (e.g. 6h for the week view) and returns them sorted by time.
func (h *AggregatedHistory) ToBuckets(size time.Duration) []Bucket {
secs := int64(size.Seconds())
if secs < 1 {
secs = 1
}
merged := map[int64]*Bucket{}
for _, ab := range h.Buckets {
ts := ab.Time.Unix() / secs * secs
b, ok := merged[ts]
if !ok {
b = &Bucket{Time: time.Unix(ts, 0)}
merged[ts] = b
}
b.Requests += ab.Requests
b.Bytes += ab.Bytes
}
out := make([]Bucket, 0, len(merged))
for _, b := range merged {
out = append(out, *b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
// ParseAggregatedOutput parses the concatenated output of per-file server-side
// awk aggregations. Sections may repeat (one set per log file) and are merged:
// bucket counts sum by timestamp, host/IP counts sum by key, totals sum.
//
// Per-file format:
//
// BUCKETS
// <unix_ts> <count> <bytes> <2xx> <3xx> <4xx> <5xx>
// HOSTS
// <count> <host>
// IPS
// <count> <ip>
// TOTALS
// <total_reqs> <total_bytes> <duration_sum_secs>
func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) {
h := &AggregatedHistory{
TopHosts: make(map[string]int64),
TopIPs: make(map[string]int64),
}
bucketsByTS := map[int64]*AggregatedBucket{}
section := ""
sawSection := false
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
switch line {
case "BUCKETS", "HOSTS", "IPS", "TOTALS":
section = line
sawSection = true
continue
}
fields := strings.Fields(line)
switch section {
case "BUCKETS":
if len(fields) < 7 {
continue
}
ts, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
continue
}
ab, ok := bucketsByTS[ts]
if !ok {
ab = &AggregatedBucket{Time: time.Unix(ts, 0)}
bucketsByTS[ts] = ab
}
n := func(i int) int64 { v, _ := strconv.ParseInt(fields[i], 10, 64); return v }
ab.Requests += n(1)
ab.Bytes += n(2)
ab.Status2xx += n(3)
ab.Status3xx += n(4)
ab.Status4xx += n(5)
ab.Status5xx += n(6)
case "HOSTS":
if len(fields) < 2 {
continue
}
count, _ := strconv.ParseInt(fields[0], 10, 64)
host := strings.Join(fields[1:], " ")
h.TopHosts[host] += count
case "IPS":
if len(fields) < 2 {
continue
}
count, _ := strconv.ParseInt(fields[0], 10, 64)
h.TopIPs[fields[1]] += count
case "TOTALS":
if len(fields) < 2 {
continue
}
reqs, _ := strconv.ParseInt(fields[0], 10, 64)
bytes, _ := strconv.ParseInt(fields[1], 10, 64)
h.TotalReqs += reqs
h.TotalBytes += bytes
if len(fields) >= 3 {
dur, _ := strconv.ParseFloat(fields[2], 64)
h.DurSumSecs += dur
}
}
}
if !sawSection {
return nil, fmt.Errorf("no aggregation output received from server")
}
h.Buckets = make([]AggregatedBucket, 0, len(bucketsByTS))
for _, ab := range bucketsByTS {
h.Buckets = append(h.Buckets, *ab)
}
sort.Slice(h.Buckets, func(i, j int) bool { return h.Buckets[i].Time.Before(h.Buckets[j].Time) })
return h, nil
}