Caddyview/internal/tui/app.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

361 lines
8.9 KiB
Go

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
}