Caddyview/internal/tui/app.go
SW 68911b3af4
All checks were successful
Release / release (push) Successful in 1m4s
chore: rename module path to git.janvete.com/seth/caddyview
The repo lives on Forgejo; the github.com module path was a leftover
identifier. No functional change.
2026-10-17 22:42:21 +02:00

783 lines
20 KiB
Go

package tui
import (
"fmt"
"math"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.janvete.com/seth/caddyview/internal/parser"
"git.janvete.com/seth/caddyview/internal/sshclient"
)
// -- tabs ---------------------------------------------------------------------
type viewTab int
const (
tabLive viewTab = 0
tabDay viewTab = 1
tabWeek viewTab = 2
tabErrors viewTab = 3
)
var tabLabels = []string{"[1] LIVE", "[2] 1 DAY", "[3] 1 WEEK", "[4] ERRORS"}
// tabWindow returns how far back each history tab reaches.
func tabWindow(t viewTab) time.Duration {
if t == tabWeek {
return 7 * 24 * time.Hour
}
return 24 * time.Hour
}
// tabBucketSize returns the bucket granularity for the bar chart.
func tabBucketSize(t viewTab) time.Duration {
if t == tabWeek {
return 6 * time.Hour
}
return time.Hour
}
// -- styles (green phosphor / amber CRT palette) --------------------------------
var (
colGreen = lipgloss.Color("40") // primary phosphor green
colBright = lipgloss.Color("46") // highlights
colDark = lipgloss.Color("28") // borders
colFaint = lipgloss.Color("22") // empty bar fill
colDim = lipgloss.Color("65") // secondary text
colAmber = lipgloss.Color("214") // warnings
colRed = lipgloss.Color("160") // errors
styleBorder = lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(colDark).
Padding(0, 1)
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(colBright)
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(colGreen)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("16")).
Background(colGreen).
Padding(0, 1)
styleTabInactive = lipgloss.NewStyle().
Foreground(colDim).
Padding(0, 1)
styleGood = lipgloss.NewStyle().Foreground(colGreen)
styleWarn = lipgloss.NewStyle().Foreground(colAmber)
styleBad = lipgloss.NewStyle().Foreground(colRed)
styleDim = lipgloss.NewStyle().Foreground(colDim)
styleBar = lipgloss.NewStyle().Foreground(colGreen)
styleBarEmpty = lipgloss.NewStyle().Foreground(colFaint)
)
// -- history state ------------------------------------------------------------
const historyRefreshInterval = 5 * time.Minute
type historyState struct {
stats *parser.Stats
buckets []parser.Bucket
errors []parser.ErrorEntry
loaded bool
loading bool
loadedAt time.Time
err error
}
// -- messages -----------------------------------------------------------------
type tickMsg time.Time
type newLinesMsg []string
type sysStatsMsg struct {
cpu float64
memUsed int64
memTotal int64
}
type historyLoadedMsg struct {
tab viewTab
agg *parser.AggregatedHistory
err error
}
type errMsg struct{ err error }
// -- model --------------------------------------------------------------------
type model struct {
client *sshclient.Client
logPath string
lineCh chan string
doneCh chan struct{}
liveStats *parser.Stats
liveStart time.Time
activeTab viewTab
history [2]*historyState
cpu float64
memUsed int64
memTotal int64
ticks int
width int
height int
err error
errorsScrollOffset int
}
func initialModel(client *sshclient.Client, logPath string) model {
m := model{
client: client,
logPath: logPath,
lineCh: make(chan string, 8192),
doneCh: make(chan struct{}),
liveStats: parser.NewStats(),
liveStart: time.Now(),
width: 120,
height: 40,
}
for i := range m.history {
m.history[i] = &historyState{}
}
return m
}
// -- init ---------------------------------------------------------------------
func (m model) Init() tea.Cmd {
return tea.Batch(
startStreaming(m.client, m.logPath, m.lineCh, m.doneCh),
tick(),
fetchSysStats(m.client),
)
}
// -- commands -----------------------------------------------------------------
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) })
}
const sysStatsEvery = 5 // seconds; each fetch opens SSH sessions and sleeps 0.5s server-side
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}
}
}
// drainLines grabs everything currently buffered in the channel as one batch,
// so a traffic burst costs one Update/render cycle instead of one per line.
func drainLines(ch chan string) tea.Cmd {
return func() tea.Msg {
var batch []string
for {
select {
case line := <-ch:
batch = append(batch, line)
if len(batch) >= 10000 {
return newLinesMsg(batch)
}
default:
if len(batch) == 0 {
return nil
}
return newLinesMsg(batch)
}
}
}
}
func loadHistory(client *sshclient.Client, logPath string, tab viewTab) tea.Cmd {
window := tabWindow(tab)
since := time.Now().Add(-window).Unix()
maxAgeDays := int(window.Hours()/24) + 1
return func() tea.Msg {
agg, err := client.LoadAggregatedHistory(logPath, since, maxAgeDays)
if err != nil {
return historyLoadedMsg{tab: tab, err: err}
}
agg.FilterSince(since)
return historyLoadedMsg{tab: tab, agg: agg}
}
}
// -- 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.MouseMsg:
if m.activeTab == tabErrors {
switch msg.Button {
case tea.MouseButtonWheelUp:
if m.errorsScrollOffset > 0 {
m.errorsScrollOffset--
}
case tea.MouseButtonWheelDown:
m.errorsScrollOffset++
}
}
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
close(m.doneCh)
return m, tea.Quit
case "1":
m.activeTab = tabLive
m.errorsScrollOffset = 0
case "2":
m.activeTab = tabDay
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabDay)
case "3":
m.activeTab = tabWeek
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabWeek)
case "4":
m.activeTab = tabErrors
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabDay)
}
case tickMsg:
m.ticks++
cmds := []tea.Cmd{tick(), drainLines(m.lineCh)}
if m.ticks%sysStatsEvery == 0 {
cmds = append(cmds, fetchSysStats(m.client))
}
if m.activeTab == tabErrors {
if cmd := m.maybeRefreshHistory(tabDay); cmd != nil {
cmds = append(cmds, cmd)
}
} else if m.activeTab != tabLive {
if cmd := m.maybeRefreshHistory(m.activeTab); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
case newLinesMsg:
for _, line := range msg {
if e := parser.ParseLine(line); e != nil {
m.liveStats.Add(e)
}
}
return m, drainLines(m.lineCh)
case sysStatsMsg:
m.cpu, m.memUsed, m.memTotal = msg.cpu, msg.memUsed, msg.memTotal
case historyLoadedMsg:
idx := int(msg.tab) - 1
h := m.history[idx]
h.loading = false
h.loaded = true
h.loadedAt = time.Now()
h.err = msg.err
if msg.err == nil && msg.agg != nil {
h.stats = msg.agg.ToStats()
h.buckets = msg.agg.ToBuckets(tabBucketSize(msg.tab))
h.errors = msg.agg.RecentErrors(100)
m.errorsScrollOffset = 0
}
case errMsg:
m.err = msg.err
}
return m, nil
}
func (m *model) ensureHistory(tab viewTab) tea.Cmd {
h := m.history[int(tab)-1]
if h.loading {
return nil
}
if !h.loaded {
h.loading = true // first load: show spinner
return loadHistory(m.client, m.logPath, tab)
}
if time.Since(h.loadedAt) > historyRefreshInterval {
// silent refresh: keep showing old data while new data loads
return loadHistory(m.client, m.logPath, tab)
}
return nil
}
// maybeRefreshHistory triggers a silent background reload for the active history
// tab if its data is older than historyRefreshInterval.
func (m *model) maybeRefreshHistory(tab viewTab) tea.Cmd {
h := m.history[int(tab)-1]
if h.loading || !h.loaded {
return nil
}
if time.Since(h.loadedAt) < historyRefreshInterval {
return nil
}
return loadHistory(m.client, m.logPath, tab)
}
// -- view ---------------------------------------------------------------------
func (m model) View() string {
if m.err != nil {
return styleBad.Render(fmt.Sprintf("ERROR: %v\n\nPress q to quit.", m.err))
}
parts := []string{
m.renderHeader(),
m.renderTabBar(),
}
switch m.activeTab {
case tabLive:
parts = append(parts, m.renderLive())
case tabDay:
parts = append(parts, m.renderHistory(0, "LAST 24 HOURS"))
case tabWeek:
parts = append(parts, m.renderHistory(1, "LAST 7 DAYS"))
case tabErrors:
parts = append(parts, m.renderErrors())
}
parts = append(parts, styleDim.Render(" [1] LIVE [2] 1 DAY [3] 1 WEEK [4] ERRORS [Q] QUIT"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m model) renderHeader() string {
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
}
row := strings.Join([]string{
styleTitle.Render("░▒▓ CADDYVIEW ▓▒░"),
" ",
styleHeader.Render("HOST: ") + m.client.Host(),
" ",
styleHeader.Render("LOG: ") + styleDim.Render(m.logPath),
" ",
styleHeader.Render("UP: ") + time.Since(m.liveStart).Round(time.Second).String(),
" ",
styleHeader.Render("CPU: ") + cpuColor.Render(fmt.Sprintf("%.1f%%", m.cpu)),
" ",
styleHeader.Render("MEM: ") + memColor.Render(fmt.Sprintf("%d/%d MB (%.0f%%)", m.memUsed, m.memTotal, memPct)),
}, "")
return lipgloss.NewStyle().
BorderBottom(true).BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(colDark).Width(m.width).
Render(row)
}
func (m model) renderTabBar() string {
tabs := make([]string, len(tabLabels))
for i, label := range tabLabels {
if viewTab(i) == m.activeTab {
tabs[i] = styleTabActive.Render(label)
} else {
tabs[i] = styleTabInactive.Render(label)
}
}
return lipgloss.NewStyle().
BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).
BorderForeground(colDark).Width(m.width).
Render(strings.Join(tabs, " "))
}
// -- live view ----------------------------------------------------------------
func (m model) renderLive() string {
elapsed := time.Since(m.liveStart).Seconds()
rps := 0.0
if elapsed > 0 {
rps = float64(m.liveStats.TotalRequests) / elapsed
}
statsBox := styleBorder.Render(strings.Join([]string{
styleHeader.Render("LIVE STATS"),
"",
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(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/s", formatBytes(int64(m.liveStats.BytesPerSec(elapsed)))),
fmt.Sprintf("Avg resp : %.1fms", m.liveStats.AvgDurationMs),
}, "\n"))
return lipgloss.JoinHorizontal(lipgloss.Top,
statsBox,
m.renderTopHosts(m.liveStats),
m.renderTopIPs(m.liveStats),
m.renderStatusCodes(m.liveStats),
)
}
// -- history view -------------------------------------------------------------
func (m model) renderHistory(idx int, label string) string {
h := m.history[idx]
if h.loading {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleWarn.Render("▒▒ LOADING HISTORY ▒▒"))
}
if !h.loaded {
return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded."))
}
if h.err != nil {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleBad.Render(fmt.Sprintf("ERROR: %v", h.err)))
}
if h.stats == nil || h.stats.TotalRequests == 0 {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleDim.Render("No log entries found for this period."))
}
// Append "updated X ago" to label so the chart title reflects freshness
age := time.Since(h.loadedAt).Round(time.Second)
labelWithAge := fmt.Sprintf("%s — updated %s ago", label, age)
tab := viewTab(idx + 1)
chart := m.renderBarChart(h.buckets, tabBucketSize(tab), labelWithAge, h.stats)
bottom := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPeriodStats(h.stats),
m.renderTopHosts(h.stats),
m.renderTopIPs(h.stats),
m.renderStatusCodes(h.stats),
)
return lipgloss.JoinVertical(lipgloss.Left, chart, bottom)
}
// -- errors view --------------------------------------------------------------
func (m model) renderErrors() string {
h := m.history[0] // errors are loaded together with the 1 DAY history
if h.loading {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleWarn.Render("▒▒ LOADING ERRORS ▒▒"))
}
if !h.loaded {
return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded."))
}
if h.err != nil {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleBad.Render(fmt.Sprintf("ERROR: %v", h.err)))
}
errs := h.errors
if len(errs) == 0 {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleDim.Render("No 4xx/5xx requests found in the last 24 hours."))
}
age := time.Since(h.loadedAt).Round(time.Second)
title := fmt.Sprintf("RECENT 4xx/5xx ERRORS — %d entries — updated %s ago", len(errs), age)
// Column widths (responsive, but fixed for simplicity)
timeW := 19
statusW := 7
methodW := 7
hostW := 28
ipW := 16
uriW := m.width - timeW - statusW - methodW - hostW - ipW - 14
if uriW < 20 {
uriW = 20
}
header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %s",
timeW, "TIME",
statusW, "STATUS",
methodW, "METHOD",
hostW, "HOST",
ipW, "REMOTE IP",
"URI",
)
// Fixed-height overhead: header + tab bar + title/header rows + footer/borders.
const overhead = 10
maxRows := m.height - overhead
if maxRows < 3 {
maxRows = 3
}
// Clamp scroll offset.
if m.errorsScrollOffset > len(errs)-maxRows {
if len(errs) > maxRows {
m.errorsScrollOffset = len(errs) - maxRows
} else {
m.errorsScrollOffset = 0
}
}
if m.errorsScrollOffset < 0 {
m.errorsScrollOffset = 0
}
end := m.errorsScrollOffset + maxRows
if end > len(errs) {
end = len(errs)
}
visible := errs[m.errorsScrollOffset:end]
rows := []string{styleHeader.Render(title), "", styleHeader.Render(header)}
for _, e := range visible {
statusStr := fmt.Sprintf("%d", e.Status)
var statusStyled string
if e.Status >= 500 {
statusStyled = styleBad.Render(statusStr)
} else {
statusStyled = styleWarn.Render(statusStr)
}
host := truncate(e.Host, hostW-1)
uri := truncate(e.URI, uriW-1)
rows = append(rows, fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %s",
timeW, e.Time.Format("2006-01-02 15:04:05"),
statusW, statusStyled,
methodW, e.Method,
hostW, host,
ipW, e.RemoteIP,
uri,
))
}
return styleBorder.Width(m.width - 4).Render(strings.Join(rows, "\n"))
}
// -- bar chart ----------------------------------------------------------------
func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, label string, stats *parser.Stats) string {
if len(buckets) == 0 {
return styleBorder.Width(m.width - 4).Render(styleDim.Render("No data for this period."))
}
sorted := make([]parser.Bucket, len(buckets))
copy(sorted, buckets)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Time.Before(sorted[j].Time) })
var maxReqs int64
for _, b := range sorted {
if b.Requests > maxReqs {
maxReqs = b.Requests
}
}
// Pick label format based on bucket size
timeFmt := "15:04"
switch {
case bucketSize >= 24*time.Hour:
timeFmt = "02 Jan"
case bucketSize >= 6*time.Hour:
timeFmt = "Mon 15h"
}
labelW := len(timeFmt) + 1
countW := len(fmt.Sprintf("%d", maxReqs)) + 2
barMax := m.width - labelW - countW - 8
if barMax < 10 {
barMax = 10
}
title := fmt.Sprintf("REQUESTS / %s — %s — TOTAL: %s req, %s",
bucketSizeLabel(bucketSize), label,
formatComma(stats.TotalRequests), formatBytes(stats.TotalBytes),
)
rows := []string{styleHeader.Render(title), ""}
for _, b := range sorted {
barLen := 0
if maxReqs > 0 {
barLen = int(math.Round(float64(b.Requests) / float64(maxReqs) * float64(barMax)))
}
bar := styleBar.Render(strings.Repeat("▓", barLen)) +
styleBarEmpty.Render(strings.Repeat("░", barMax-barLen))
rows = append(rows, fmt.Sprintf("%-*s %s %s",
labelW, b.Time.Format(timeFmt),
bar,
styleDim.Render(formatComma(b.Requests)),
))
}
return styleBorder.Width(m.width - 4).Render(strings.Join(rows, "\n"))
}
// -- shared panels ------------------------------------------------------------
func (m model) renderPeriodStats(stats *parser.Stats) string {
return styleBorder.Render(strings.Join([]string{
styleHeader.Render("PERIOD STATS"),
"",
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(stats.TotalRequests))),
fmt.Sprintf("Transfer : %s", formatBytes(stats.TotalBytes)),
fmt.Sprintf("Avg resp : %.1fms", stats.AvgDurationMs),
}, "\n"))
}
func (m model) renderTopHosts(stats *parser.Stats) string {
rows := []string{styleHeader.Render("TOP HOSTS"), ""}
for _, p := range topN(stats.TopHosts, 8) {
host := p.key
if len(host) > 30 {
host = host[:27] + "..."
}
rows = append(rows, fmt.Sprintf("%-30s %s", host, styleGood.Render(formatComma(p.val))))
}
if len(stats.TopHosts) == 0 {
rows = append(rows, styleDim.Render("no data"))
}
return styleBorder.Render(strings.Join(rows, "\n"))
}
func (m model) renderTopIPs(stats *parser.Stats) string {
rows := []string{styleHeader.Render("TOP IPS"), ""}
for _, p := range topN(stats.TopIPs, 8) {
rows = append(rows, fmt.Sprintf("%-20s %s", p.key, styleGood.Render(formatComma(p.val))))
}
if len(stats.TopIPs) == 0 {
rows = append(rows, styleDim.Render("no data"))
}
return styleBorder.Render(strings.Join(rows, "\n"))
}
func (m model) renderStatusCodes(stats *parser.Stats) string {
groups := map[string]int64{"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0}
for code, n := range stats.StatusCodes {
switch {
case code >= 200 && code < 300:
groups["2xx"] += n
case code >= 300 && code < 400:
groups["3xx"] += n
case code >= 400 && code < 500:
groups["4xx"] += n
case code >= 500:
groups["5xx"] += n
}
}
rows := []string{styleHeader.Render("STATUS CODES"), ""}
for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} {
s := formatComma(groups[g])
var colored string
switch g {
case "2xx":
colored = styleGood.Render(s)
case "3xx":
colored = styleDim.Render(s)
case "4xx":
colored = styleWarn.Render(s)
case "5xx":
colored = styleBad.Render(s)
}
rows = append(rows, fmt.Sprintf("%s : %s", g, colored))
}
return styleBorder.Render(strings.Join(rows, "\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)
}
}
func formatComma(n int64) string {
s := fmt.Sprintf("%d", n)
out := ""
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
out += ","
}
out += string(c)
}
return out
}
type kv struct {
key string
val int64
}
// topN returns the n highest entries. Ties are broken alphabetically so rows
// with equal counts keep a stable order between renders instead of flickering.
func topN(m map[string]int64, n int) []kv {
pairs := make([]kv, 0, len(m))
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].val != pairs[j].val {
return pairs[i].val > pairs[j].val
}
return pairs[i].key < pairs[j].key
})
if len(pairs) > n {
return pairs[:n]
}
return pairs
}
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
func bucketSizeLabel(d time.Duration) string {
switch {
case d >= 24*time.Hour:
return "DAY"
case d >= time.Hour:
return fmt.Sprintf("%dH", int(d.Hours()))
default:
return fmt.Sprintf("%dM", int(d.Minutes()))
}
}
// -- entry point --------------------------------------------------------------
func Run(client *sshclient.Client, logPath string) error {
p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()
return err
}