Caddyview/internal/tui/app.go
SW da6a128882 fix: auto-refresh history tabs every 5 minutes
History tabs were loaded once at first visit and never updated,
causing 1 Day/Week/Month to show fewer requests than the live view.

- Silent background refresh every 5 min for the active history tab
- Re-check on tab switch: reload if data is older than 5 min
- Spinner only on first load; subsequent refreshes keep old data visible
- Chart title shows "updated X ago" so freshness is always visible

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

633 lines
16 KiB
Go

package tui
import (
"fmt"
"math"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/janvete/caddyview/internal/parser"
"github.com/janvete/caddyview/internal/sshclient"
)
// -- tabs ---------------------------------------------------------------------
type viewTab int
const (
tabLive viewTab = 0
tabDay viewTab = 1
tabWeek viewTab = 2
tabMonth viewTab = 3
)
var tabLabels = []string{"[1] Live", "[2] 1 Day", "[3] 1 Week", "[4] 1 Month"}
// tabWindow returns how far back each history tab reaches.
func tabWindow(t viewTab) time.Duration {
switch t {
case tabDay:
return 24 * time.Hour
case tabWeek:
return 7 * 24 * time.Hour
case tabMonth:
return 30 * 24 * time.Hour
}
return 0
}
// tabBucketSize returns the bucket granularity for the bar chart.
func tabBucketSize(t viewTab) time.Duration {
switch t {
case tabDay:
return time.Hour
case tabWeek:
return 6 * time.Hour
case tabMonth:
return 24 * time.Hour
}
return time.Hour
}
// -- 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"))
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("16")).
Background(lipgloss.Color("62")).
Padding(0, 1)
styleTabInactive = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Padding(0, 1)
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"))
styleBar = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
)
// -- history state ------------------------------------------------------------
const historyRefreshInterval = 5 * time.Minute
type historyState struct {
entries []*parser.LogEntry
stats *parser.Stats
buckets []parser.Bucket
loaded bool
loading bool
loadedAt time.Time
err error
}
// -- messages -----------------------------------------------------------------
type tickMsg time.Time
type newLineMsg string
type sysStatsMsg struct {
cpu float64
memUsed int64
memTotal int64
}
type historyLoadedMsg struct {
tab viewTab
entries []*parser.LogEntry
err error
}
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
activeTab viewTab
history [3]*historyState
cpu float64
memUsed int64
memTotal int64
width int
height int
err error
}
func initialModel(client *sshclient.Client, logPath string) model {
m := model{
client: client,
logPath: logPath,
lineCh: make(chan string, 256),
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) })
}
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
}
}
}
func loadHistory(client *sshclient.Client, logPath string, tab viewTab) tea.Cmd {
since := time.Now().Add(-tabWindow(tab)).Unix()
return func() tea.Msg {
lines, err := client.LoadHistoricalLines(logPath, since)
if err != nil {
return historyLoadedMsg{tab: tab, err: err}
}
return historyLoadedMsg{tab: tab, entries: parser.ParseLines(lines)}
}
}
// -- 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:
switch msg.String() {
case "q", "ctrl+c":
close(m.doneCh)
return m, tea.Quit
case "1":
m.activeTab = tabLive
case "2":
m.activeTab = tabDay
return m, m.ensureHistory(tabDay)
case "3":
m.activeTab = tabWeek
return m, m.ensureHistory(tabWeek)
case "4":
m.activeTab = tabMonth
return m, m.ensureHistory(tabMonth)
}
case tickMsg:
cmds := []tea.Cmd{tick(), drainLines(m.lineCh), fetchSysStats(m.client)}
if m.activeTab != tabLive {
if cmd := m.maybeRefreshHistory(m.activeTab); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
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, 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 {
h.entries = msg.entries
h.stats = parser.ComputeStats(msg.entries)
h.buckets = parser.Bucketize(msg.entries, tabBucketSize(msg.tab))
}
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 tabMonth:
parts = append(parts, m.renderHistory(2, "last 30 days"))
}
parts = append(parts, styleDim.Render(" 1 live 2 day 3 week 4 month 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.NormalBorder()).
BorderForeground(lipgloss.Color("62")).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(lipgloss.Color("62")).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 historical data…"))
}
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 len(h.entries) == 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)
}
// -- 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 := strings.Repeat("█", barLen)
rows = append(rows, fmt.Sprintf("%-*s %s %s",
labelW, b.Time.Format(timeFmt),
styleBar.Render(fmt.Sprintf("%-*s", barMax, 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
}
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 { return pairs[i].val > pairs[j].val })
if len(pairs) > n {
return pairs[:n]
}
return pairs
}
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())
_, err := p.Run()
return err
}