From 7c6adbc17e10615d01b27c462e17c97a1a8ec459 Mon Sep 17 00:00:00 2001 From: SW Date: Fri, 12 Jun 2026 21:30:38 +0200 Subject: [PATCH] fix: rework history aggregation, live streaming and visual refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History was effectively frozen: the find pattern 'access.log*' never matched Caddy's native rotation naming (access-.log.gz), so rotated files were invisible and the server-side cache - invalidated only by new .gz files - never refreshed after the first load. - match both Caddy native (access-*.log.gz) and logrotate (access.log.N) rotation naming - cache aggregation per rotated file (immutable, scanned exactly once); always scan the current log fresh, so history never goes stale - always bucket hourly on the server, re-bucket client-side so all tabs share the same caches - cap IPs at top 50 per file so output stays small on busy servers - aggregate request duration so history shows real avg response time - tail -F instead of -f: Caddy rotates by rename, which silently killed the live stream at every rotation on high-traffic servers - process live lines in batches instead of one Update cycle per line - fetch CPU/MEM stats every 5s instead of every second - stable sort in topN: equal counts no longer flicker and swap rows - drop the 1 Month tab (Live / 1 Day / 1 Week remain) - retro green-phosphor CRT look: double borders, ▓░ bars, amber accents Co-Authored-By: Claude Fable 5 --- internal/parser/caddy.go | 155 ++++++++++++++++------------ internal/sshclient/client.go | 126 ++++++++++++----------- internal/tui/app.go | 189 +++++++++++++++++++---------------- 3 files changed, 262 insertions(+), 208 deletions(-) diff --git a/internal/parser/caddy.go b/internal/parser/caddy.go index 235547a..0f2fb73 100644 --- a/internal/parser/caddy.go +++ b/internal/parser/caddy.go @@ -3,6 +3,7 @@ package parser import ( "encoding/json" "fmt" + "sort" "strconv" "strings" "time" @@ -47,17 +48,6 @@ func ParseLine(line string) *LogEntry { return &entry } -// ParseLines parses multiple lines and returns valid entries. -func ParseLines(lines []string) []*LogEntry { - entries := make([]*LogEntry, 0, len(lines)) - for _, l := range lines { - if e := ParseLine(l); e != nil { - entries = append(entries, e) - } - } - return entries -} - // -- Stats (used for live view and derived from AggregatedHistory for history) -- type Stats struct { @@ -100,14 +90,6 @@ func (s *Stats) BytesPerSec(windowSecs float64) float64 { return float64(s.TotalBytes) / windowSecs } -func ComputeStats(entries []*LogEntry) *Stats { - s := NewStats() - for _, e := range entries { - s.Add(e) - } - return s -} - // -- Bucket (time-series bar chart data) -------------------------------------- type Bucket struct { @@ -116,43 +98,47 @@ type Bucket struct { Bytes int64 } -func Bucketize(entries []*LogEntry, bucketSize time.Duration) []Bucket { - if len(entries) == 0 { - return nil - } - buckets := map[int64]*Bucket{} - for _, e := range entries { - t := e.Time().Truncate(bucketSize) - key := t.Unix() - if _, ok := buckets[key]; !ok { - buckets[key] = &Bucket{Time: t} - } - buckets[key].Requests++ - buckets[key].Bytes += e.Size - } - result := make([]Bucket, 0, len(buckets)) - for _, b := range buckets { - result = append(result, *b) - } - return result -} - // -- AggregatedHistory (server-side aggregation result) ----------------------- // AggregatedBucket is one time bucket returned by server-side awk aggregation. type AggregatedBucket struct { - Time time.Time - Requests, Bytes int64 + Time time.Time + Requests, Bytes int64 Status2xx, Status3xx, Status4xx, Status5xx int64 } -// AggregatedHistory holds the full result of a server-side history aggregation. +// AggregatedHistory holds the merged result of per-file server-side aggregations. type AggregatedHistory struct { Buckets []AggregatedBucket TopHosts map[string]int64 TopIPs map[string]int64 TotalReqs int64 TotalBytes int64 + DurSumSecs float64 +} + +// FilterSince drops buckets older than the window and recomputes totals from +// the remaining buckets, so the chart and the totals always agree. TopHosts and +// TopIPs come from whole rotated files and may slightly overcount at the oldest +// edge of the window — acceptable for a monitoring view. +func (h *AggregatedHistory) FilterSince(since int64) { + avgDur := 0.0 + if h.TotalReqs > 0 { + avgDur = h.DurSumSecs / float64(h.TotalReqs) + } + kept := h.Buckets[:0] + var reqs, bytes int64 + for _, b := range h.Buckets { + if b.Time.Unix() >= since { + kept = append(kept, b) + reqs += b.Requests + bytes += b.Bytes + } + } + h.Buckets = kept + h.TotalReqs = reqs + h.TotalBytes = bytes + h.DurSumSecs = avgDur * float64(reqs) } // ToStats converts aggregated history to a Stats object for use in the TUI panels. @@ -162,6 +148,9 @@ func (h *AggregatedHistory) ToStats() *Stats { s.TotalBytes = h.TotalBytes s.TopHosts = h.TopHosts s.TopIPs = h.TopIPs + if h.TotalReqs > 0 { + s.AvgDurationMs = h.DurSumSecs / float64(h.TotalReqs) * 1000 + } for _, b := range h.Buckets { s.StatusCodes[200] += b.Status2xx s.StatusCodes[301] += b.Status3xx @@ -171,18 +160,37 @@ func (h *AggregatedHistory) ToStats() *Stats { return s } -// ToBuckets converts aggregated buckets to the Bucket slice used by the bar chart. -func (h *AggregatedHistory) ToBuckets() []Bucket { - out := make([]Bucket, len(h.Buckets)) - for i, ab := range h.Buckets { - out[i] = Bucket{Time: ab.Time, Requests: ab.Requests, Bytes: ab.Bytes} +// ToBuckets re-buckets the hourly server-side buckets into the granularity the +// chart wants (e.g. 6h for the week view) and returns them sorted by time. +func (h *AggregatedHistory) ToBuckets(size time.Duration) []Bucket { + secs := int64(size.Seconds()) + if secs < 1 { + secs = 1 } + merged := map[int64]*Bucket{} + for _, ab := range h.Buckets { + ts := ab.Time.Unix() / secs * secs + b, ok := merged[ts] + if !ok { + b = &Bucket{Time: time.Unix(ts, 0)} + merged[ts] = b + } + b.Requests += ab.Requests + b.Bytes += ab.Bytes + } + out := make([]Bucket, 0, len(merged)) + for _, b := range merged { + out = append(out, *b) + } + sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) }) return out } -// ParseAggregatedOutput parses the output of the server-side awk aggregation script. +// ParseAggregatedOutput parses the concatenated output of per-file server-side +// awk aggregations. Sections may repeat (one set per log file) and are merged: +// bucket counts sum by timestamp, host/IP counts sum by key, totals sum. // -// Format: +// Per-file format: // // BUCKETS // <2xx> <3xx> <4xx> <5xx> @@ -191,14 +199,16 @@ func (h *AggregatedHistory) ToBuckets() []Bucket { // IPS // // TOTALS -// +// func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) { h := &AggregatedHistory{ TopHosts: make(map[string]int64), TopIPs: make(map[string]int64), } + bucketsByTS := map[int64]*AggregatedBucket{} section := "" + sawSection := false for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { line = strings.TrimSpace(line) if line == "" { @@ -207,6 +217,7 @@ func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) { switch line { case "BUCKETS", "HOSTS", "IPS", "TOTALS": section = line + sawSection = true continue } @@ -220,14 +231,18 @@ func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) { if err != nil { continue } - ab := AggregatedBucket{Time: time.Unix(ts, 0)} - ab.Requests, _ = strconv.ParseInt(fields[1], 10, 64) - ab.Bytes, _ = strconv.ParseInt(fields[2], 10, 64) - ab.Status2xx, _ = strconv.ParseInt(fields[3], 10, 64) - ab.Status3xx, _ = strconv.ParseInt(fields[4], 10, 64) - ab.Status4xx, _ = strconv.ParseInt(fields[5], 10, 64) - ab.Status5xx, _ = strconv.ParseInt(fields[6], 10, 64) - h.Buckets = append(h.Buckets, ab) + ab, ok := bucketsByTS[ts] + if !ok { + ab = &AggregatedBucket{Time: time.Unix(ts, 0)} + bucketsByTS[ts] = ab + } + n := func(i int) int64 { v, _ := strconv.ParseInt(fields[i], 10, 64); return v } + ab.Requests += n(1) + ab.Bytes += n(2) + ab.Status2xx += n(3) + ab.Status3xx += n(4) + ab.Status4xx += n(5) + ab.Status5xx += n(6) case "HOSTS": if len(fields) < 2 { @@ -235,26 +250,38 @@ func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) { } count, _ := strconv.ParseInt(fields[0], 10, 64) host := strings.Join(fields[1:], " ") - h.TopHosts[host] = count + h.TopHosts[host] += count case "IPS": if len(fields) < 2 { continue } count, _ := strconv.ParseInt(fields[0], 10, 64) - h.TopIPs[fields[1]] = count + h.TopIPs[fields[1]] += count case "TOTALS": if len(fields) < 2 { continue } - h.TotalReqs, _ = strconv.ParseInt(fields[0], 10, 64) - h.TotalBytes, _ = strconv.ParseInt(fields[1], 10, 64) + reqs, _ := strconv.ParseInt(fields[0], 10, 64) + bytes, _ := strconv.ParseInt(fields[1], 10, 64) + h.TotalReqs += reqs + h.TotalBytes += bytes + if len(fields) >= 3 { + dur, _ := strconv.ParseFloat(fields[2], 64) + h.DurSumSecs += dur + } } } - if section == "" { + if !sawSection { return nil, fmt.Errorf("no aggregation output received from server") } + + h.Buckets = make([]AggregatedBucket, 0, len(bucketsByTS)) + for _, ab := range bucketsByTS { + h.Buckets = append(h.Buckets, *ab) + } + sort.Slice(h.Buckets, func(i, j int) bool { return h.Buckets[i].Time.Before(h.Buckets[j].Time) }) return h, nil } diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index f7e2c4b..1d17a03 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -64,20 +64,7 @@ func (c *Client) RunCommand(cmd string) (string, error) { return string(out), err } -// TailLines reads the last n lines of a remote file. -func (c *Client) TailLines(path string, n int) ([]string, error) { - out, err := c.RunCommand(fmt.Sprintf("tail -n %d %s 2>/dev/null", n, path)) - if err != nil { - return nil, fmt.Errorf("cannot read %s: %w", path, err) - } - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) == 1 && lines[0] == "" { - return nil, nil - } - return lines, nil -} - -// StreamLines streams new lines from a remote file (like tail -f) into the channel. +// StreamLines streams new lines from a remote file (like tail -F) into the channel. func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struct{}) error { session, err := c.conn.NewSession() if err != nil { @@ -90,7 +77,9 @@ func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struc return err } - if err := session.Start(fmt.Sprintf("tail -n 0 -f %s 2>/dev/null", path)); err != nil { + // -F follows by name: Caddy rotates by renaming the file, which would + // silently kill a plain -f stream at every rotation. + if err := session.Start(fmt.Sprintf("tail -n 0 -F %s 2>/dev/null", path)); err != nil { session.Close() return err } @@ -98,6 +87,7 @@ func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struc go func() { defer session.Close() scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { select { case <-done: @@ -241,27 +231,11 @@ func parseCPUStat(raw string) float64 { return (totalDiff - idleDiff) / totalDiff * 100 } -// LoadAggregatedHistory aggregates log data on the remote server using awk, -// returning only summary buckets instead of raw lines. This is critical for -// high-traffic servers where raw line transfer would be impractical. -// -// bucketSecs: size of each time bucket in seconds (e.g. 3600 for hourly) -// maxAgeDays: find only files modified within this many days (performance filter) -func (c *Client) LoadAggregatedHistory(logPath string, since, bucketSecs int64, maxAgeDays int) (*parser.AggregatedHistory, error) { - lastSlash := strings.LastIndex(logPath, "/") - dir, base := "/", logPath - if lastSlash >= 0 { - dir = logPath[:lastSlash] - if dir == "" { - dir = "/" - } - base = logPath[lastSlash+1:] - } - - // awk aggregation script — runs entirely on the server. - // Parses JSON log lines, groups by time bucket, outputs summary. - // Uses only POSIX-compatible awk constructs. - awkProg := ` +// historyAwkProg aggregates Caddy JSON log lines into hourly summary buckets. +// It runs entirely on the server and uses only POSIX awk constructs (works +// with gawk, mawk and busybox awk). IPs are capped to the top 50 per file so +// the output stays small even on servers with millions of unique clients. +const historyAwkProg = ` BEGIN { OFS=" " } { if (index($0,"\"msg\":\"handled request\"") == 0) next @@ -274,6 +248,8 @@ BEGIN { OFS=" " } st = (p>0) ? substr($0,p+9,5)+0 : 0 p = index($0,"\"size\":") sz = (p>0) ? substr($0,p+7,15)+0 : 0 + p = index($0,"\"duration\":") + du = (p>0) ? substr($0,p+11,14)+0 : 0 p = index($0,"\"host\":\"") if (p>0) { rest=substr($0,p+8); q=index(rest,"\""); ho=(q>0)?substr(rest,1,q-1):"?" } else ho="?" p = index($0,"\"remote_ip\":\"") @@ -284,7 +260,7 @@ BEGIN { OFS=" " } else if (st>=300) bs3[b]++ else if (st>=200) bs2[b]++ hc[ho]++; ic[ip]++ - tot++; totb+=sz + tot++; totb+=sz; totd+=du } END { print "BUCKETS" @@ -292,35 +268,69 @@ END { print "HOSTS" for (h in hc) print hc[h], h print "IPS" - for (i in ic) print ic[i], i + k = 50 + for (i in ic) n++ + while (k > 0 && n > 0) { + max = -1; mi = "" + for (i in ic) if (ic[i] > max) { max = ic[i]; mi = i } + print ic[mi], mi + delete ic[mi] + k--; n-- + } print "TOTALS" - print tot+0, totb+0 + print tot+0, totb+0, totd+0 }` - // Cache file is keyed by log path + bucket size (not since, which changes every second). - // Cache is valid as long as no new .gz file appeared since it was written. - // A log rotation (every ~9 min on high-traffic servers) is the only invalidation event. - cacheKey := strings.NewReplacer("/", "_", ".", "_", "-", "_").Replace(base) - cachePath := fmt.Sprintf("/tmp/caddyview_%s_%d.cache", cacheKey, bucketSecs) +// LoadAggregatedHistory aggregates log data on the remote server using awk, +// returning only summary buckets instead of raw lines. This is critical for +// high-traffic servers where raw line transfer would be impractical. +// +// Rotated log files are immutable, so each one is aggregated exactly once and +// the result is cached on the server (/tmp/caddyview_*.cache); after a +// rotation only the single new file is scanned. The current log file is always +// scanned fresh, so the view never goes stale. Buckets are always hourly; the +// caller re-buckets client-side, which lets all tabs share the same caches. +// +// since: unix cutoff for the current (still growing) log file +// maxAgeDays: only look at files modified within this many days +func (c *Client) LoadAggregatedHistory(logPath string, since int64, maxAgeDays int) (*parser.AggregatedHistory, error) { + lastSlash := strings.LastIndex(logPath, "/") + dir, base := "/", logPath + if lastSlash >= 0 { + dir = logPath[:lastSlash] + if dir == "" { + dir = "/" + } + base = logPath[lastSlash+1:] + } - findPipe := fmt.Sprintf( - `find %s -name '%s*' -type f -mtime -%d 2>/dev/null | sort -r | `+ - `while read f; do case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac; done 2>/dev/null | `+ - `awk -v cutoff=%d -v bucket=%d '%s'`, - dir, base, maxAgeDays+1, since, bucketSecs, awkProg, + // Match both Caddy's native rotation naming (access-2026-06-12T10-30-00.000.log + // and .log.gz next to access.log) and classic logrotate naming (access.log.1.gz). + stem, ext := base, "" + if dot := strings.LastIndex(base, "."); dot > 0 { + stem, ext = base[:dot], base[dot:] + } + namePattern := fmt.Sprintf( + `\( -name '%s' -o -name '%s.*' -o -name '%s-*%s' -o -name '%s-*%s.gz' \)`, + base, base, stem, ext, stem, ext, ) - // Wrap with server-side cache: hit → instant cat; miss → run awk and tee to cache atomically. cmd := fmt.Sprintf( - `CACHE=%s; `+ - `LATEST=$(find %s -name '%s*.gz' -mtime -%d 2>/dev/null | sort | tail -1); `+ - `if [ -f "$CACHE" ] && { [ -z "$LATEST" ] || [ "$CACHE" -nt "$LATEST" ]; }; then `+ - `cat "$CACHE"; `+ + `find /tmp -maxdepth 1 -name 'caddyview_*' -mtime +8 -delete 2>/dev/null; `+ + `find %s %s -type f -mtime -%d 2>/dev/null | sort | while read -r f; do `+ + `if [ "$f" = "%s" ]; then `+ + `awk -v cutoff=%d -v bucket=3600 '%s' < "$f"; `+ `else `+ - `TMPFILE=$(mktemp /tmp/caddyview.XXXXXX 2>/dev/null || echo /tmp/caddyview_fallback.tmp); `+ - `%s | tee "$TMPFILE" && mv "$TMPFILE" "$CACHE" 2>/dev/null || cat "$TMPFILE" 2>/dev/null; `+ - `fi`, - cachePath, dir, base, maxAgeDays+1, findPipe, + `key=$(printf '%%s' "$f" | tr -c 'A-Za-z0-9' '_'); c="/tmp/caddyview_${key}.cache"; `+ + `if [ -s "$c" ] && [ "$c" -nt "$f" ]; then cat "$c"; else `+ + `t=$(mktemp /tmp/caddyview.XXXXXX 2>/dev/null) || t="/tmp/caddyview_tmp.$$"; `+ + `case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac 2>/dev/null `+ + `| awk -v cutoff=0 -v bucket=3600 '%s' > "$t" && mv "$t" "$c" && cat "$c" || cat "$t" 2>/dev/null; `+ + `fi; fi; done`, + dir, namePattern, maxAgeDays+1, + logPath, + since, historyAwkProg, + historyAwkProg, ) out, err := c.RunCommand(cmd) diff --git a/internal/tui/app.go b/internal/tui/app.go index 413a7d8..4b477df 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -18,67 +18,65 @@ import ( type viewTab int const ( - tabLive viewTab = 0 - tabDay viewTab = 1 - tabWeek viewTab = 2 - tabMonth viewTab = 3 + tabLive viewTab = 0 + tabDay viewTab = 1 + tabWeek viewTab = 2 ) -var tabLabels = []string{"[1] Live", "[2] 1 Day", "[3] 1 Week", "[4] 1 Month"} +var tabLabels = []string{"[1] LIVE", "[2] 1 DAY", "[3] 1 WEEK"} // 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: + if t == tabWeek { return 7 * 24 * time.Hour - case tabMonth: - return 30 * 24 * time.Hour } - return 0 + return 24 * time.Hour } // tabBucketSize returns the bucket granularity for the bar chart. func tabBucketSize(t viewTab) time.Duration { - switch t { - case tabDay: - return time.Hour - case tabWeek: + if t == tabWeek { return 6 * time.Hour - case tabMonth: - return 24 * time.Hour } return time.Hour } -// -- styles ------------------------------------------------------------------- +// -- 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.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). + Border(lipgloss.DoubleBorder()). + BorderForeground(colDark). Padding(0, 1) - styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) + styleTitle = lipgloss.NewStyle().Bold(true).Foreground(colBright) - styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + styleHeader = lipgloss.NewStyle().Bold(true).Foreground(colGreen) styleTabActive = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("16")). - Background(lipgloss.Color("62")). + Background(colGreen). Padding(0, 1) styleTabInactive = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). + Foreground(colDim). 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")) + 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 ------------------------------------------------------------ @@ -97,16 +95,16 @@ type historyState struct { // -- messages ----------------------------------------------------------------- type tickMsg time.Time -type newLineMsg string +type newLinesMsg []string type sysStatsMsg struct { cpu float64 memUsed int64 memTotal int64 } type historyLoadedMsg struct { - tab viewTab - agg *parser.AggregatedHistory - err error + tab viewTab + agg *parser.AggregatedHistory + err error } type errMsg struct{ err error } @@ -118,17 +116,17 @@ type model struct { lineCh chan string doneCh chan struct{} - liveEntries []*parser.LogEntry - liveStats *parser.Stats - liveStart time.Time + liveStats *parser.Stats + liveStart time.Time activeTab viewTab - history [3]*historyState + history [2]*historyState cpu float64 memUsed int64 memTotal int64 + ticks int width int height int err error @@ -138,7 +136,7 @@ func initialModel(client *sshclient.Client, logPath string) model { m := model{ client: client, logPath: logPath, - lineCh: make(chan string, 256), + lineCh: make(chan string, 8192), doneCh: make(chan struct{}), liveStats: parser.NewStats(), liveStart: time.Now(), @@ -176,6 +174,8 @@ 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() @@ -186,26 +186,38 @@ func fetchSysStats(client *sshclient.Client) tea.Cmd { } } +// 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 { - select { - case line := <-ch: - return newLineMsg(line) - default: - return nil + 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 { - since := time.Now().Add(-tabWindow(tab)).Unix() - bucketSecs := int64(tabBucketSize(tab).Seconds()) - maxAgeDays := int(tabWindow(tab).Hours()/24) + 1 + 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, bucketSecs, maxAgeDays) + 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} } } @@ -232,13 +244,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)} + m.ticks++ + cmds := []tea.Cmd{tick(), drainLines(m.lineCh)} + if m.ticks%sysStatsEvery == 0 { + cmds = append(cmds, fetchSysStats(m.client)) + } if m.activeTab != tabLive { if cmd := m.maybeRefreshHistory(m.activeTab); cmd != nil { cmds = append(cmds, cmd) @@ -246,12 +259,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.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:] + case newLinesMsg: + for _, line := range msg { + if e := parser.ParseLine(line); e != nil { + m.liveStats.Add(e) } } return m, drainLines(m.lineCh) @@ -268,7 +279,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { h.err = msg.err if msg.err == nil && msg.agg != nil { h.stats = msg.agg.ToStats() - h.buckets = msg.agg.ToBuckets() + h.buckets = msg.agg.ToBuckets(tabBucketSize(msg.tab)) } case errMsg: @@ -311,7 +322,7 @@ func (m *model) maybeRefreshHistory(tab viewTab) tea.Cmd { func (m model) View() string { if m.err != nil { - return styleBad.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err)) + return styleBad.Render(fmt.Sprintf("ERROR: %v\n\nPress q to quit.", m.err)) } parts := []string{ m.renderHeader(), @@ -321,13 +332,11 @@ func (m model) View() string { case tabLive: parts = append(parts, m.renderLive()) case tabDay: - parts = append(parts, m.renderHistory(0, "last 24 hours")) + 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, m.renderHistory(1, "LAST 7 DAYS")) } - parts = append(parts, styleDim.Render(" 1 live 2 day 3 week 4 month q quit")) + parts = append(parts, styleDim.Render(" [1] LIVE [2] 1 DAY [3] 1 WEEK [Q] QUIT")) return lipgloss.JoinVertical(lipgloss.Left, parts...) } @@ -349,21 +358,21 @@ func (m model) renderHeader() string { memColor = styleWarn } row := strings.Join([]string{ - styleTitle.Render("caddyview"), + styleTitle.Render("░▒▓ CADDYVIEW ▓▒░"), " ", - styleHeader.Render("host: ") + m.client.Host(), + styleHeader.Render("HOST: ") + m.client.Host(), " ", - styleHeader.Render("log: ") + styleDim.Render(m.logPath), + styleHeader.Render("LOG: ") + styleDim.Render(m.logPath), " ", - styleHeader.Render("up: ") + time.Since(m.liveStart).Round(time.Second).String(), + 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). + BorderBottom(true).BorderStyle(lipgloss.DoubleBorder()). + BorderForeground(colDark).Width(m.width). Render(row) } @@ -378,7 +387,7 @@ func (m model) renderTabBar() string { } return lipgloss.NewStyle(). BorderBottom(true).BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("62")).Width(m.width). + BorderForeground(colDark).Width(m.width). Render(strings.Join(tabs, " ")) } @@ -391,7 +400,7 @@ func (m model) renderLive() string { rps = float64(m.liveStats.TotalRequests) / elapsed } statsBox := styleBorder.Render(strings.Join([]string{ - styleHeader.Render("Live Stats"), + 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))), @@ -413,14 +422,14 @@ 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…")) + 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))) + Render(styleBad.Render(fmt.Sprintf("ERROR: %v", h.err))) } if h.stats == nil || h.stats.TotalRequests == 0 { return lipgloss.NewStyle().Padding(2, 4). @@ -476,7 +485,7 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, barMax = 10 } - title := fmt.Sprintf("Requests / %s — %s — total: %s req, %s", + title := fmt.Sprintf("REQUESTS / %s — %s — TOTAL: %s req, %s", bucketSizeLabel(bucketSize), label, formatComma(stats.TotalRequests), formatBytes(stats.TotalBytes), ) @@ -487,10 +496,11 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, if maxReqs > 0 { barLen = int(math.Round(float64(b.Requests) / float64(maxReqs) * float64(barMax))) } - bar := strings.Repeat("█", barLen) + 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), - styleBar.Render(fmt.Sprintf("%-*s", barMax, bar)), + bar, styleDim.Render(formatComma(b.Requests)), )) } @@ -502,7 +512,7 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, func (m model) renderPeriodStats(stats *parser.Stats) string { return styleBorder.Render(strings.Join([]string{ - styleHeader.Render("Period Stats"), + styleHeader.Render("PERIOD STATS"), "", fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(stats.TotalRequests))), fmt.Sprintf("Transfer : %s", formatBytes(stats.TotalBytes)), @@ -511,7 +521,7 @@ func (m model) renderPeriodStats(stats *parser.Stats) string { } func (m model) renderTopHosts(stats *parser.Stats) string { - rows := []string{styleHeader.Render("Top Hosts"), ""} + rows := []string{styleHeader.Render("TOP HOSTS"), ""} for _, p := range topN(stats.TopHosts, 8) { host := p.key if len(host) > 30 { @@ -526,7 +536,7 @@ func (m model) renderTopHosts(stats *parser.Stats) string { } func (m model) renderTopIPs(stats *parser.Stats) string { - rows := []string{styleHeader.Render("Top IPs"), ""} + 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)))) } @@ -550,7 +560,7 @@ func (m model) renderStatusCodes(stats *parser.Stats) string { groups["5xx"] += n } } - rows := []string{styleHeader.Render("Status Codes"), ""} + rows := []string{styleHeader.Render("STATUS CODES"), ""} for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} { s := formatComma(groups[g]) var colored string @@ -601,12 +611,19 @@ type kv struct { 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 { return pairs[i].val > pairs[j].val }) + 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] } @@ -616,11 +633,11 @@ func topN(m map[string]int64, n int) []kv { func bucketSizeLabel(d time.Duration) string { switch { case d >= 24*time.Hour: - return "day" + return "DAY" case d >= time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) + return fmt.Sprintf("%dH", int(d.Hours())) default: - return fmt.Sprintf("%dm", int(d.Minutes())) + return fmt.Sprintf("%dM", int(d.Minutes())) } }