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 }