From 0c57089abfa110cd3e3beb9705a16afcf7de4e8b Mon Sep 17 00:00:00 2001 From: SW Date: Thu, 11 Jun 2026 09:50:09 +0200 Subject: [PATCH] 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 --- cmd/root.go | 18 ++ cmd/ssh.go | 44 +++++ go.mod | 29 +++ go.sum | 57 ++++++ internal/parser/caddy.go | 130 +++++++++++++ internal/sshclient/client.go | 221 +++++++++++++++++++++ internal/tui/app.go | 361 +++++++++++++++++++++++++++++++++++ main.go | 7 + 8 files changed, 867 insertions(+) create mode 100644 cmd/root.go create mode 100644 cmd/ssh.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/parser/caddy.go create mode 100644 internal/sshclient/client.go create mode 100644 internal/tui/app.go create mode 100644 main.go diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..2c4a805 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "caddyview", + Short: "TUI monitor for Caddy web server logs over SSH", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/ssh.go b/cmd/ssh.go new file mode 100644 index 0000000..bcd7f48 --- /dev/null +++ b/cmd/ssh.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/janvete/caddyview/internal/sshclient" + "github.com/janvete/caddyview/internal/tui" + "github.com/spf13/cobra" +) + +var ( + sshPort int + logPath string + sshKeyPath string +) + +var sshCmd = &cobra.Command{ + Use: "ssh [user@host]", + Short: "Connect to server and monitor Caddy logs", + Example: ` caddyview ssh root@192.168.1.1 + caddyview ssh -p 2222 admin@myserver.com + caddyview ssh -l /var/log/caddy/access.log root@192.168.1.1`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + target := args[0] + + client, err := sshclient.Connect(target, sshPort, sshKeyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "SSH connection failed: %v\n", err) + os.Exit(1) + } + defer client.Close() + + return tui.Run(client, logPath) + }, +} + +func init() { + rootCmd.AddCommand(sshCmd) + sshCmd.Flags().IntVarP(&sshPort, "port", "p", 22, "SSH port") + sshCmd.Flags().StringVarP(&logPath, "log", "l", "/var/log/caddy/access.log", "Path to Caddy access log on remote server") + sshCmd.Flags().StringVarP(&sshKeyPath, "key", "i", "", "SSH private key path (default: ~/.ssh/id_rsa or id_ed25519)") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bfb2e16 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/janvete/caddyview + +go 1.26.4 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e5244d --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/parser/caddy.go b/internal/parser/caddy.go new file mode 100644 index 0000000..b37c200 --- /dev/null +++ b/internal/parser/caddy.go @@ -0,0 +1,130 @@ +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 +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go new file mode 100644 index 0000000..371486d --- /dev/null +++ b/internal/sshclient/client.go @@ -0,0 +1,221 @@ +package sshclient + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" +) + +type Client struct { + conn *ssh.Client + host string +} + +func Connect(target string, port int, keyPath string) (*Client, error) { + user, host, err := parseTarget(target) + if err != nil { + return nil, err + } + + authMethods, err := buildAuthMethods(keyPath) + if err != nil { + return nil, fmt.Errorf("auth setup failed: %w", err) + } + + config := &ssh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("dial %s: %w", addr, err) + } + + return &Client{conn: conn, host: host}, nil +} + +func (c *Client) Close() { + c.conn.Close() +} + +func (c *Client) Host() string { + return c.host +} + +// RunCommand runs a command and returns its output as a string. +func (c *Client) RunCommand(cmd string) (string, error) { + session, err := c.conn.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + out, err := session.Output(cmd) + return string(out), err +} + +// TailLines reads the last n lines of a remote file. +func (c *Client) TailLines(path string, n int) ([]string, error) { + out, err := c.RunCommand(fmt.Sprintf("tail -n %d %s 2>/dev/null", n, path)) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", path, err) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 1 && lines[0] == "" { + return nil, nil + } + return lines, nil +} + +// StreamLines streams new lines from a remote file (like tail -f) into the channel. +func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struct{}) error { + session, err := c.conn.NewSession() + if err != nil { + return err + } + + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + return err + } + + if err := session.Start(fmt.Sprintf("tail -n 0 -f %s 2>/dev/null", path)); err != nil { + session.Close() + return err + } + + go func() { + defer session.Close() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + select { + case <-done: + return + case lines <- scanner.Text(): + } + } + }() + + go func() { + <-done + session.Close() + }() + + return nil +} + +// SysStats fetches CPU and memory usage from the remote server. +func (c *Client) SysStats() (cpuPercent float64, memUsedMB, memTotalMB int64, err error) { + // Memory from /proc/meminfo + memOut, err := c.RunCommand("cat /proc/meminfo") + if err != nil { + return 0, 0, 0, err + } + memTotalMB, memUsedMB = parseMeminfo(memOut) + + // CPU: two /proc/stat samples 500ms apart + cpuOut, err := c.RunCommand("cat /proc/stat; sleep 0.5; cat /proc/stat") + if err != nil { + return 0, memUsedMB, memTotalMB, err + } + cpuPercent = parseCPUStat(cpuOut) + + return cpuPercent, memUsedMB, memTotalMB, nil +} + +func parseTarget(target string) (user, host string, err error) { + parts := strings.SplitN(target, "@", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid target %q — expected user@host", target) + } + return parts[0], parts[1], nil +} + +func buildAuthMethods(keyPath string) ([]ssh.AuthMethod, error) { + candidates := []string{} + if keyPath != "" { + candidates = append(candidates, keyPath) + } else { + home, _ := os.UserHomeDir() + candidates = append(candidates, + filepath.Join(home, ".ssh", "id_ed25519"), + filepath.Join(home, ".ssh", "id_rsa"), + ) + } + + for _, p := range candidates { + data, err := os.ReadFile(p) + if err != nil { + continue + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + continue + } + return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil + } + + return nil, fmt.Errorf("no usable SSH private key found (tried: %v)", candidates) +} + +func parseMeminfo(raw string) (totalMB, usedMB int64) { + var total, available int64 + for _, line := range strings.Split(raw, "\n") { + var val int64 + if _, err := fmt.Sscanf(line, "MemTotal: %d kB", &val); err == nil { + total = val + } + if _, err := fmt.Sscanf(line, "MemAvailable: %d kB", &val); err == nil { + available = val + } + } + return total / 1024, (total - available) / 1024 +} + +func parseCPUStat(raw string) float64 { + type cpuSample struct{ user, nice, sys, idle, iowait, irq, softirq uint64 } + parse := func(line string) (cpuSample, bool) { + var s cpuSample + var label string + n, _ := fmt.Sscanf(line, "%s %d %d %d %d %d %d %d", + &label, &s.user, &s.nice, &s.sys, &s.idle, &s.iowait, &s.irq, &s.softirq) + return s, n >= 5 && label == "cpu" + } + + var samples []cpuSample + for _, line := range strings.Split(raw, "\n") { + if s, ok := parse(line); ok { + samples = append(samples, s) + if len(samples) == 2 { + break + } + } + } + if len(samples) < 2 { + return 0 + } + + total1 := samples[0].user + samples[0].nice + samples[0].sys + samples[0].idle + samples[0].iowait + samples[0].irq + samples[0].softirq + total2 := samples[1].user + samples[1].nice + samples[1].sys + samples[1].idle + samples[1].iowait + samples[1].irq + samples[1].softirq + idle1 := samples[0].idle + samples[0].iowait + idle2 := samples[1].idle + samples[1].iowait + + totalDiff := float64(total2 - total1) + idleDiff := float64(idle2 - idle1) + if totalDiff == 0 { + return 0 + } + return (totalDiff - idleDiff) / totalDiff * 100 +} + +// ensure net import is used (used in future dial variants) +var _ = net.IPv4len diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..6b6960e --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,361 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/janvete/caddyview/internal/parser" + "github.com/janvete/caddyview/internal/sshclient" +) + +// -- styles ------------------------------------------------------------------- + +var ( + styleBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(0, 1) + + styleTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")) + + styleHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + + styleGood = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + styleBad = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + + styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) +) + +// -- messages ----------------------------------------------------------------- + +type tickMsg time.Time +type newLineMsg string +type sysStatsMsg struct { + cpu float64 + memUsed int64 + memTotal int64 +} +type errMsg struct{ err error } + +// -- model -------------------------------------------------------------------- + +type model struct { + client *sshclient.Client + logPath string + lineCh chan string + doneCh chan struct{} + + liveEntries []*parser.LogEntry + liveStats *parser.Stats + liveStart time.Time + + cpu float64 + memUsed int64 + memTotal int64 + + width int + height int + err error +} + +func initialModel(client *sshclient.Client, logPath string) model { + return model{ + client: client, + logPath: logPath, + lineCh: make(chan string, 256), + doneCh: make(chan struct{}), + liveStats: parser.NewStats(), + liveStart: time.Now(), + width: 120, + height: 40, + } +} + +// -- init --------------------------------------------------------------------- + +func (m model) Init() tea.Cmd { + return tea.Batch( + startStreaming(m.client, m.logPath, m.lineCh, m.doneCh), + tick(), + fetchSysStats(m.client), + ) +} + +func startStreaming(client *sshclient.Client, logPath string, ch chan string, done chan struct{}) tea.Cmd { + return func() tea.Msg { + if err := client.StreamLines(logPath, ch, done); err != nil { + return errMsg{err} + } + return nil + } +} + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func fetchSysStats(client *sshclient.Client) tea.Cmd { + return func() tea.Msg { + cpu, used, total, err := client.SysStats() + if err != nil { + return sysStatsMsg{} + } + return sysStatsMsg{cpu: cpu, memUsed: used, memTotal: total} + } +} + +func drainLines(ch chan string) tea.Cmd { + return func() tea.Msg { + select { + case line := <-ch: + return newLineMsg(line) + default: + return nil + } + } +} + +// -- update ------------------------------------------------------------------- + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + if msg.String() == "q" || msg.String() == "ctrl+c" { + close(m.doneCh) + return m, tea.Quit + } + + case tickMsg: + return m, tea.Batch(tick(), drainLines(m.lineCh), fetchSysStats(m.client)) + + case newLineMsg: + if e := parser.ParseLine(string(msg)); e != nil { + m.liveStats.Add(e) + m.liveEntries = append(m.liveEntries, e) + if len(m.liveEntries) > 10000 { + m.liveEntries = m.liveEntries[len(m.liveEntries)-10000:] + } + } + return m, drainLines(m.lineCh) + + case sysStatsMsg: + m.cpu = msg.cpu + m.memUsed = msg.memUsed + m.memTotal = msg.memTotal + + case errMsg: + m.err = msg.err + } + + return m, nil +} + +// -- view --------------------------------------------------------------------- + +func (m model) View() string { + if m.err != nil { + return styleBad.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err)) + } + + header := m.renderHeader() + stats := m.renderStats() + hosts := m.renderTopHosts() + ips := m.renderTopIPs() + status := m.renderStatusCodes() + footer := styleDim.Render(" q quit") + + top := lipgloss.JoinHorizontal(lipgloss.Top, stats, hosts, ips, status) + return lipgloss.JoinVertical(lipgloss.Left, header, top, footer) +} + +func (m model) renderHeader() string { + elapsed := time.Since(m.liveStart).Round(time.Second) + cpuColor := styleGood + if m.cpu > 80 { + cpuColor = styleBad + } else if m.cpu > 50 { + cpuColor = styleWarn + } + + memPct := 0.0 + if m.memTotal > 0 { + memPct = float64(m.memUsed) / float64(m.memTotal) * 100 + } + memColor := styleGood + if memPct > 80 { + memColor = styleBad + } else if memPct > 60 { + memColor = styleWarn + } + + parts := []string{ + styleTitle.Render("caddyview"), + styleDim.Render(" "), + styleHeader.Render("host: ") + m.client.Host(), + styleDim.Render(" "), + styleHeader.Render("log: ") + styleDim.Render(m.logPath), + styleDim.Render(" "), + styleHeader.Render("uptime: ") + elapsed.String(), + styleDim.Render(" "), + styleHeader.Render("CPU: ") + cpuColor.Render(fmt.Sprintf("%.1f%%", m.cpu)), + styleDim.Render(" "), + styleHeader.Render("MEM: ") + memColor.Render(fmt.Sprintf("%d/%d MB (%.0f%%)", m.memUsed, m.memTotal, memPct)), + } + + return lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("62")). + Width(m.width). + Render(strings.Join(parts, "")) +} + +func (m model) renderStats() string { + elapsed := time.Since(m.liveStart).Seconds() + rps := float64(m.liveStats.TotalRequests) + if elapsed > 0 { + rps = float64(m.liveStats.TotalRequests) / elapsed + } + bps := m.liveStats.BytesPerSec(elapsed) + + content := strings.Join([]string{ + styleHeader.Render("Live Stats"), + "", + fmt.Sprintf("Requests : %s", styleGood.Render(fmt.Sprintf("%d", m.liveStats.TotalRequests))), + fmt.Sprintf("Req/sec : %s", styleGood.Render(fmt.Sprintf("%.2f", rps))), + fmt.Sprintf("Transfer : %s", formatBytes(m.liveStats.TotalBytes)), + fmt.Sprintf("Speed : %s", formatBytes(int64(bps))+"/s"), + fmt.Sprintf("Avg resp : %.1fms", m.liveStats.AvgDurationMs), + }, "\n") + + return styleBorder.Render(content) +} + +func (m model) renderTopHosts() string { + lines := []string{styleHeader.Render("Top Hosts"), ""} + type kv struct { + k string + v int64 + } + pairs := make([]kv, 0, len(m.liveStats.TopHosts)) + for k, v := range m.liveStats.TopHosts { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v }) + if len(pairs) > 8 { + pairs = pairs[:8] + } + for _, p := range pairs { + host := p.k + if len(host) > 30 { + host = host[:27] + "..." + } + lines = append(lines, fmt.Sprintf("%-30s %s", host, styleGood.Render(fmt.Sprintf("%d", p.v)))) + } + if len(pairs) == 0 { + lines = append(lines, styleDim.Render("waiting for requests...")) + } + return styleBorder.Render(strings.Join(lines, "\n")) +} + +func (m model) renderTopIPs() string { + lines := []string{styleHeader.Render("Top IPs"), ""} + type kv struct { + k string + v int64 + } + pairs := make([]kv, 0, len(m.liveStats.TopIPs)) + for k, v := range m.liveStats.TopIPs { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v }) + if len(pairs) > 8 { + pairs = pairs[:8] + } + for _, p := range pairs { + lines = append(lines, fmt.Sprintf("%-20s %s", p.k, styleGood.Render(fmt.Sprintf("%d", p.v)))) + } + if len(pairs) == 0 { + lines = append(lines, styleDim.Render("waiting for requests...")) + } + return styleBorder.Render(strings.Join(lines, "\n")) +} + +func (m model) renderStatusCodes() string { + lines := []string{styleHeader.Render("Status Codes"), ""} + + groups := map[string]int64{ + "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, + } + for code, count := range m.liveStats.StatusCodes { + switch { + case code >= 200 && code < 300: + groups["2xx"] += count + case code >= 300 && code < 400: + groups["3xx"] += count + case code >= 400 && code < 500: + groups["4xx"] += count + case code >= 500: + groups["5xx"] += count + } + } + + colorFor := func(g string, v int64) string { + s := fmt.Sprintf("%d", v) + switch g { + case "2xx": + return styleGood.Render(s) + case "3xx": + return styleDim.Render(s) + case "4xx": + return styleWarn.Render(s) + case "5xx": + return styleBad.Render(s) + } + return s + } + + for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} { + lines = append(lines, fmt.Sprintf("%s : %s", g, colorFor(g, groups[g]))) + } + return styleBorder.Render(strings.Join(lines, "\n")) +} + +// -- helpers ------------------------------------------------------------------ + +func formatBytes(b int64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.2f GB", float64(b)/(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.2f MB", float64(b)/(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.2f KB", float64(b)/(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} + +// -- entry point -------------------------------------------------------------- + +func Run(client *sshclient.Client, logPath string) error { + p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ac24d1c --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/janvete/caddyview/cmd" + +func main() { + cmd.Execute() +}