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>
This commit is contained in:
SW 2026-06-11 09:50:09 +02:00
commit 0c57089abf
8 changed files with 867 additions and 0 deletions

18
cmd/root.go Normal file
View file

@ -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)
}
}

44
cmd/ssh.go Normal file
View file

@ -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)")
}

29
go.mod Normal file
View file

@ -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
)

57
go.sum Normal file
View file

@ -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=

130
internal/parser/caddy.go Normal file
View file

@ -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
}

View file

@ -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

361
internal/tui/app.go Normal file
View file

@ -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
}

7
main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "github.com/janvete/caddyview/cmd"
func main() {
cmd.Execute()
}