diff --git a/internal/parser/caddy.go b/internal/parser/caddy.go index b37c200..219b10e 100644 --- a/internal/parser/caddy.go +++ b/internal/parser/caddy.go @@ -105,6 +105,15 @@ type Bucket struct { Bytes int64 } +// ComputeStats builds a Stats object from a slice of log entries. +func ComputeStats(entries []*LogEntry) *Stats { + s := NewStats() + for _, e := range entries { + s.Add(e) + } + return s +} + // Bucketize groups entries into buckets of the given duration. func Bucketize(entries []*LogEntry, bucketSize time.Duration) []Bucket { if len(entries) == 0 { diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 371486d..eece631 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -3,7 +3,6 @@ package sshclient import ( "bufio" "fmt" - "net" "os" "path/filepath" "strings" @@ -217,5 +216,35 @@ func parseCPUStat(raw string) float64 { return (totalDiff - idleDiff) / totalDiff * 100 } -// ensure net import is used (used in future dial variants) -var _ = net.IPv4len +// LoadHistoricalLines reads log lines newer than `since` from the main log file +// and any rotated versions (access.log.1, access.log.2.gz, etc.). +func (c *Client) LoadHistoricalLines(logPath string, since int64) ([]string, error) { + lastSlash := strings.LastIndex(logPath, "/") + dir, base := "/", logPath + if lastSlash >= 0 { + dir = logPath[:lastSlash] + if dir == "" { + dir = "/" + } + base = logPath[lastSlash+1:] + } + + // Find all rotated log files, cat/zcat them, filter JSON lines by ts field. + // awk: find offset of "ts": then parse the numeric value after it. + cmd := fmt.Sprintf( + `find %s -name '%s*' -type f 2>/dev/null | sort -r | `+ + `while read f; do case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac; done 2>/dev/null | `+ + `awk -v c=%d '{p=index($0,"\"ts\":"); if(p>0){v=substr($0,p+5,20)+0; if(v>=c) print}}'`, + dir, base, since, + ) + + out, err := c.RunCommand(cmd) + if err != nil && strings.TrimSpace(out) == "" { + return nil, fmt.Errorf("failed to read historical logs: %w", err) + } + out = strings.TrimSpace(out) + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 6b6960e..654553f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "math" "sort" "strings" "time" @@ -12,6 +13,45 @@ import ( "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 ( @@ -20,44 +60,69 @@ var ( BorderForeground(lipgloss.Color("62")). Padding(0, 1) - styleTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("212")) + styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - styleHeader = lipgloss.NewStyle(). + styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + + styleTabActive = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("39")) + 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")) + styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + styleBar = lipgloss.NewStyle().Foreground(lipgloss.Color("62")) ) +// -- history state ------------------------------------------------------------ + +type historyState struct { + entries []*parser.LogEntry + stats *parser.Stats + buckets []parser.Bucket + loaded bool + loading bool + err error +} + // -- messages ----------------------------------------------------------------- type tickMsg time.Time type newLineMsg string type sysStatsMsg struct { - cpu float64 - memUsed int64 - memTotal int64 + 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{} + 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 @@ -68,7 +133,7 @@ type model struct { } func initialModel(client *sshclient.Client, logPath string) model { - return model{ + m := model{ client: client, logPath: logPath, lineCh: make(chan string, 256), @@ -78,6 +143,10 @@ func initialModel(client *sshclient.Client, logPath string) model { width: 120, height: 40, } + for i := range m.history { + m.history[i] = &historyState{} + } + return m } // -- init --------------------------------------------------------------------- @@ -90,6 +159,8 @@ func (m model) Init() tea.Cmd { ) } +// -- 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 { @@ -100,9 +171,7 @@ func startStreaming(client *sshclient.Client, logPath string, ch chan string, do } func tick() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func fetchSysStats(client *sshclient.Client) tea.Cmd { @@ -126,6 +195,17 @@ func drainLines(ch chan string) tea.Cmd { } } +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) { @@ -136,9 +216,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height case tea.KeyMsg: - if msg.String() == "q" || msg.String() == "ctrl+c" { + 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: @@ -155,9 +247,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, drainLines(m.lineCh) case sysStatsMsg: - m.cpu = msg.cpu - m.memUsed = msg.memUsed - m.memTotal = msg.memTotal + 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.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 @@ -166,33 +268,46 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *model) ensureHistory(tab viewTab) tea.Cmd { + h := m.history[int(tab)-1] + if h.loaded || h.loading { + return nil + } + h.loading = true + 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)) } - - 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) + 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 { - 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 @@ -203,138 +318,221 @@ func (m model) renderHeader() string { } else if memPct > 60 { memColor = styleWarn } - - parts := []string{ + row := strings.Join([]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("up: ") + time.Since(m.liveStart).Round(time.Second).String(), + " ", 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, "")) + BorderBottom(true).BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("62")).Width(m.width). + Render(row) } -func (m model) renderStats() string { +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 := float64(m.liveStats.TotalRequests) + rps := 0.0 if elapsed > 0 { rps = float64(m.liveStats.TotalRequests) / elapsed } - bps := m.liveStats.BytesPerSec(elapsed) - - content := strings.Join([]string{ + statsBox := styleBorder.Render(strings.Join([]string{ styleHeader.Render("Live Stats"), "", - fmt.Sprintf("Requests : %s", styleGood.Render(fmt.Sprintf("%d", m.liveStats.TotalRequests))), + 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", formatBytes(int64(bps))+"/s"), + fmt.Sprintf("Speed : %s/s", formatBytes(int64(m.liveStats.BytesPerSec(elapsed)))), fmt.Sprintf("Avg resp : %.1fms", m.liveStats.AvgDurationMs), - }, "\n") - - return styleBorder.Render(content) + }, "\n")) + return lipgloss.JoinHorizontal(lipgloss.Top, + statsBox, + m.renderTopHosts(m.liveStats), + m.renderTopIPs(m.liveStats), + m.renderStatusCodes(m.liveStats), + ) } -func (m model) renderTopHosts() string { - lines := []string{styleHeader.Render("Top Hosts"), ""} - type kv struct { - k string - v int64 +// -- 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…")) } - pairs := make([]kv, 0, len(m.liveStats.TopHosts)) - for k, v := range m.liveStats.TopHosts { - pairs = append(pairs, kv{k, v}) + if !h.loaded { + return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded.")) } - sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v }) - if len(pairs) > 8 { - pairs = pairs[:8] + if h.err != nil { + return lipgloss.NewStyle().Padding(2, 4). + Render(styleBad.Render(fmt.Sprintf("Error: %v", h.err))) } - for _, p := range pairs { - host := p.k + if len(h.entries) == 0 { + return lipgloss.NewStyle().Padding(2, 4). + Render(styleDim.Render("No log entries found for this period.")) + } + + tab := viewTab(idx + 1) + chart := m.renderBarChart(h.buckets, tabBucketSize(tab), label, 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] + "..." } - lines = append(lines, fmt.Sprintf("%-30s %s", host, styleGood.Render(fmt.Sprintf("%d", p.v)))) + rows = append(rows, fmt.Sprintf("%-30s %s", host, styleGood.Render(formatComma(p.val)))) } - if len(pairs) == 0 { - lines = append(lines, styleDim.Render("waiting for requests...")) + if len(stats.TopHosts) == 0 { + rows = append(rows, styleDim.Render("no data")) } - return styleBorder.Render(strings.Join(lines, "\n")) + return styleBorder.Render(strings.Join(rows, "\n")) } -func (m model) renderTopIPs() string { - lines := []string{styleHeader.Render("Top IPs"), ""} - type kv struct { - k string - v int64 +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)))) } - pairs := make([]kv, 0, len(m.liveStats.TopIPs)) - for k, v := range m.liveStats.TopIPs { - pairs = append(pairs, kv{k, v}) + if len(stats.TopIPs) == 0 { + rows = append(rows, styleDim.Render("no data")) } - 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")) + return styleBorder.Render(strings.Join(rows, "\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 { +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"] += count + groups["2xx"] += n case code >= 300 && code < 400: - groups["3xx"] += count + groups["3xx"] += n case code >= 400 && code < 500: - groups["4xx"] += count + groups["4xx"] += n case code >= 500: - groups["5xx"] += count + groups["5xx"] += n } } - - colorFor := func(g string, v int64) string { - s := fmt.Sprintf("%d", v) + 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": - return styleGood.Render(s) + colored = styleGood.Render(s) case "3xx": - return styleDim.Render(s) + colored = styleDim.Render(s) case "4xx": - return styleWarn.Render(s) + colored = styleWarn.Render(s) case "5xx": - return styleBad.Render(s) + colored = styleBad.Render(s) } - return s + rows = append(rows, fmt.Sprintf("%s : %s", g, colored)) } - - 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")) + return styleBorder.Render(strings.Join(rows, "\n")) } // -- helpers ------------------------------------------------------------------ @@ -352,6 +550,46 @@ func formatBytes(b int64) string { } } +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 {