feat: add history views with bar chart and tab navigation
Tabs 1-4 switch between Live, 1 Day, 1 Week, 1 Month views. History loads on demand via SSH by reading rotated log files (access.log, access.log.1, access.log.2.gz, ...) filtered by timestamp on the remote side using awk. Bar chart shows requests per time bucket (1h/6h/24h depending on window). Bottom panels show period stats, top hosts, top IPs, and status codes — consistent across all views. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c57089abf
commit
b25badb5c8
3 changed files with 398 additions and 122 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue