Caddyview/internal/parser/caddy.go
SW 0c57089abf feat: initial caddyview implementation
SSH-connected TUI monitor for Caddy web server logs.
Live view with 1s refresh, request stats, top hosts/IPs,
status codes, transfer speed, CPU and RAM from /proc.

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

130 lines
2.9 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
}
// 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
}