History tabs were loaded once at first visit and never updated, causing 1 Day/Week/Month to show fewer requests than the live view. - Silent background refresh every 5 min for the active history tab - Re-check on tab switch: reload if data is older than 5 min - Spinner only on first load; subsequent refreshes keep old data visible - Chart title shows "updated X ago" so freshness is always visible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
633 lines
16 KiB
Go
633 lines
16 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/janvete/caddyview/internal/parser"
|
|
"github.com/janvete/caddyview/internal/sshclient"
|
|
)
|
|
|
|
// -- tabs ---------------------------------------------------------------------
|
|
|
|
type viewTab int
|
|
|
|
const (
|
|
tabLive viewTab = 0
|
|
tabDay viewTab = 1
|
|
tabWeek viewTab = 2
|
|
tabMonth viewTab = 3
|
|
)
|
|
|
|
var tabLabels = []string{"[1] Live", "[2] 1 Day", "[3] 1 Week", "[4] 1 Month"}
|
|
|
|
// tabWindow returns how far back each history tab reaches.
|
|
func tabWindow(t viewTab) time.Duration {
|
|
switch t {
|
|
case tabDay:
|
|
return 24 * time.Hour
|
|
case tabWeek:
|
|
return 7 * 24 * time.Hour
|
|
case tabMonth:
|
|
return 30 * 24 * time.Hour
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// tabBucketSize returns the bucket granularity for the bar chart.
|
|
func tabBucketSize(t viewTab) time.Duration {
|
|
switch t {
|
|
case tabDay:
|
|
return time.Hour
|
|
case tabWeek:
|
|
return 6 * time.Hour
|
|
case tabMonth:
|
|
return 24 * time.Hour
|
|
}
|
|
return time.Hour
|
|
}
|
|
|
|
// -- styles -------------------------------------------------------------------
|
|
|
|
var (
|
|
styleBorder = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("62")).
|
|
Padding(0, 1)
|
|
|
|
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
|
|
|
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
|
|
|
|
styleTabActive = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("16")).
|
|
Background(lipgloss.Color("62")).
|
|
Padding(0, 1)
|
|
|
|
styleTabInactive = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("240")).
|
|
Padding(0, 1)
|
|
|
|
styleGood = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
|
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
|
styleBad = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
|
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
styleBar = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
|
)
|
|
|
|
// -- history state ------------------------------------------------------------
|
|
|
|
const historyRefreshInterval = 5 * time.Minute
|
|
|
|
type historyState struct {
|
|
entries []*parser.LogEntry
|
|
stats *parser.Stats
|
|
buckets []parser.Bucket
|
|
loaded bool
|
|
loading bool
|
|
loadedAt time.Time
|
|
err error
|
|
}
|
|
|
|
// -- messages -----------------------------------------------------------------
|
|
|
|
type tickMsg time.Time
|
|
type newLineMsg string
|
|
type sysStatsMsg struct {
|
|
cpu float64
|
|
memUsed int64
|
|
memTotal int64
|
|
}
|
|
type historyLoadedMsg struct {
|
|
tab viewTab
|
|
entries []*parser.LogEntry
|
|
err error
|
|
}
|
|
type errMsg struct{ err error }
|
|
|
|
// -- model --------------------------------------------------------------------
|
|
|
|
type model struct {
|
|
client *sshclient.Client
|
|
logPath string
|
|
lineCh chan string
|
|
doneCh chan struct{}
|
|
|
|
liveEntries []*parser.LogEntry
|
|
liveStats *parser.Stats
|
|
liveStart time.Time
|
|
|
|
activeTab viewTab
|
|
history [3]*historyState
|
|
|
|
cpu float64
|
|
memUsed int64
|
|
memTotal int64
|
|
|
|
width int
|
|
height int
|
|
err error
|
|
}
|
|
|
|
func initialModel(client *sshclient.Client, logPath string) model {
|
|
m := model{
|
|
client: client,
|
|
logPath: logPath,
|
|
lineCh: make(chan string, 256),
|
|
doneCh: make(chan struct{}),
|
|
liveStats: parser.NewStats(),
|
|
liveStart: time.Now(),
|
|
width: 120,
|
|
height: 40,
|
|
}
|
|
for i := range m.history {
|
|
m.history[i] = &historyState{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// -- init ---------------------------------------------------------------------
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
startStreaming(m.client, m.logPath, m.lineCh, m.doneCh),
|
|
tick(),
|
|
fetchSysStats(m.client),
|
|
)
|
|
}
|
|
|
|
// -- commands -----------------------------------------------------------------
|
|
|
|
func startStreaming(client *sshclient.Client, logPath string, ch chan string, done chan struct{}) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.StreamLines(logPath, ch, done); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func tick() tea.Cmd {
|
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
|
}
|
|
|
|
func fetchSysStats(client *sshclient.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
cpu, used, total, err := client.SysStats()
|
|
if err != nil {
|
|
return sysStatsMsg{}
|
|
}
|
|
return sysStatsMsg{cpu: cpu, memUsed: used, memTotal: total}
|
|
}
|
|
}
|
|
|
|
func drainLines(ch chan string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
select {
|
|
case line := <-ch:
|
|
return newLineMsg(line)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadHistory(client *sshclient.Client, logPath string, tab viewTab) tea.Cmd {
|
|
since := time.Now().Add(-tabWindow(tab)).Unix()
|
|
return func() tea.Msg {
|
|
lines, err := client.LoadHistoricalLines(logPath, since)
|
|
if err != nil {
|
|
return historyLoadedMsg{tab: tab, err: err}
|
|
}
|
|
return historyLoadedMsg{tab: tab, entries: parser.ParseLines(lines)}
|
|
}
|
|
}
|
|
|
|
// -- update -------------------------------------------------------------------
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
close(m.doneCh)
|
|
return m, tea.Quit
|
|
case "1":
|
|
m.activeTab = tabLive
|
|
case "2":
|
|
m.activeTab = tabDay
|
|
return m, m.ensureHistory(tabDay)
|
|
case "3":
|
|
m.activeTab = tabWeek
|
|
return m, m.ensureHistory(tabWeek)
|
|
case "4":
|
|
m.activeTab = tabMonth
|
|
return m, m.ensureHistory(tabMonth)
|
|
}
|
|
|
|
case tickMsg:
|
|
cmds := []tea.Cmd{tick(), drainLines(m.lineCh), fetchSysStats(m.client)}
|
|
if m.activeTab != tabLive {
|
|
if cmd := m.maybeRefreshHistory(m.activeTab); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
|
|
case newLineMsg:
|
|
if e := parser.ParseLine(string(msg)); e != nil {
|
|
m.liveStats.Add(e)
|
|
m.liveEntries = append(m.liveEntries, e)
|
|
if len(m.liveEntries) > 10000 {
|
|
m.liveEntries = m.liveEntries[len(m.liveEntries)-10000:]
|
|
}
|
|
}
|
|
return m, drainLines(m.lineCh)
|
|
|
|
case sysStatsMsg:
|
|
m.cpu, m.memUsed, m.memTotal = msg.cpu, msg.memUsed, msg.memTotal
|
|
|
|
case historyLoadedMsg:
|
|
idx := int(msg.tab) - 1
|
|
h := m.history[idx]
|
|
h.loading = false
|
|
h.loaded = true
|
|
h.loadedAt = time.Now()
|
|
h.err = msg.err
|
|
if msg.err == nil {
|
|
h.entries = msg.entries
|
|
h.stats = parser.ComputeStats(msg.entries)
|
|
h.buckets = parser.Bucketize(msg.entries, tabBucketSize(msg.tab))
|
|
}
|
|
|
|
case errMsg:
|
|
m.err = msg.err
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) ensureHistory(tab viewTab) tea.Cmd {
|
|
h := m.history[int(tab)-1]
|
|
if h.loading {
|
|
return nil
|
|
}
|
|
if !h.loaded {
|
|
h.loading = true // first load: show spinner
|
|
return loadHistory(m.client, m.logPath, tab)
|
|
}
|
|
if time.Since(h.loadedAt) > historyRefreshInterval {
|
|
// silent refresh: keep showing old data while new data loads
|
|
return loadHistory(m.client, m.logPath, tab)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// maybeRefreshHistory triggers a silent background reload for the active history
|
|
// tab if its data is older than historyRefreshInterval.
|
|
func (m *model) maybeRefreshHistory(tab viewTab) tea.Cmd {
|
|
h := m.history[int(tab)-1]
|
|
if h.loading || !h.loaded {
|
|
return nil
|
|
}
|
|
if time.Since(h.loadedAt) < historyRefreshInterval {
|
|
return nil
|
|
}
|
|
return loadHistory(m.client, m.logPath, tab)
|
|
}
|
|
|
|
// -- view ---------------------------------------------------------------------
|
|
|
|
func (m model) View() string {
|
|
if m.err != nil {
|
|
return styleBad.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err))
|
|
}
|
|
parts := []string{
|
|
m.renderHeader(),
|
|
m.renderTabBar(),
|
|
}
|
|
switch m.activeTab {
|
|
case tabLive:
|
|
parts = append(parts, m.renderLive())
|
|
case tabDay:
|
|
parts = append(parts, m.renderHistory(0, "last 24 hours"))
|
|
case tabWeek:
|
|
parts = append(parts, m.renderHistory(1, "last 7 days"))
|
|
case tabMonth:
|
|
parts = append(parts, m.renderHistory(2, "last 30 days"))
|
|
}
|
|
parts = append(parts, styleDim.Render(" 1 live 2 day 3 week 4 month q quit"))
|
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
}
|
|
|
|
func (m model) renderHeader() string {
|
|
cpuColor := styleGood
|
|
if m.cpu > 80 {
|
|
cpuColor = styleBad
|
|
} else if m.cpu > 50 {
|
|
cpuColor = styleWarn
|
|
}
|
|
memPct := 0.0
|
|
if m.memTotal > 0 {
|
|
memPct = float64(m.memUsed) / float64(m.memTotal) * 100
|
|
}
|
|
memColor := styleGood
|
|
if memPct > 80 {
|
|
memColor = styleBad
|
|
} else if memPct > 60 {
|
|
memColor = styleWarn
|
|
}
|
|
row := strings.Join([]string{
|
|
styleTitle.Render("caddyview"),
|
|
" ",
|
|
styleHeader.Render("host: ") + m.client.Host(),
|
|
" ",
|
|
styleHeader.Render("log: ") + styleDim.Render(m.logPath),
|
|
" ",
|
|
styleHeader.Render("up: ") + time.Since(m.liveStart).Round(time.Second).String(),
|
|
" ",
|
|
styleHeader.Render("CPU: ") + cpuColor.Render(fmt.Sprintf("%.1f%%", m.cpu)),
|
|
" ",
|
|
styleHeader.Render("MEM: ") + memColor.Render(fmt.Sprintf("%d/%d MB (%.0f%%)", m.memUsed, m.memTotal, memPct)),
|
|
}, "")
|
|
return lipgloss.NewStyle().
|
|
BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("62")).Width(m.width).
|
|
Render(row)
|
|
}
|
|
|
|
func (m model) renderTabBar() string {
|
|
tabs := make([]string, len(tabLabels))
|
|
for i, label := range tabLabels {
|
|
if viewTab(i) == m.activeTab {
|
|
tabs[i] = styleTabActive.Render(label)
|
|
} else {
|
|
tabs[i] = styleTabInactive.Render(label)
|
|
}
|
|
}
|
|
return lipgloss.NewStyle().
|
|
BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("62")).Width(m.width).
|
|
Render(strings.Join(tabs, " "))
|
|
}
|
|
|
|
// -- live view ----------------------------------------------------------------
|
|
|
|
func (m model) renderLive() string {
|
|
elapsed := time.Since(m.liveStart).Seconds()
|
|
rps := 0.0
|
|
if elapsed > 0 {
|
|
rps = float64(m.liveStats.TotalRequests) / elapsed
|
|
}
|
|
statsBox := styleBorder.Render(strings.Join([]string{
|
|
styleHeader.Render("Live Stats"),
|
|
"",
|
|
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(m.liveStats.TotalRequests))),
|
|
fmt.Sprintf("Req/sec : %s", styleGood.Render(fmt.Sprintf("%.2f", rps))),
|
|
fmt.Sprintf("Transfer : %s", formatBytes(m.liveStats.TotalBytes)),
|
|
fmt.Sprintf("Speed : %s/s", formatBytes(int64(m.liveStats.BytesPerSec(elapsed)))),
|
|
fmt.Sprintf("Avg resp : %.1fms", m.liveStats.AvgDurationMs),
|
|
}, "\n"))
|
|
return lipgloss.JoinHorizontal(lipgloss.Top,
|
|
statsBox,
|
|
m.renderTopHosts(m.liveStats),
|
|
m.renderTopIPs(m.liveStats),
|
|
m.renderStatusCodes(m.liveStats),
|
|
)
|
|
}
|
|
|
|
// -- history view -------------------------------------------------------------
|
|
|
|
func (m model) renderHistory(idx int, label string) string {
|
|
h := m.history[idx]
|
|
if h.loading {
|
|
return lipgloss.NewStyle().Padding(2, 4).
|
|
Render(styleWarn.Render("Loading historical data…"))
|
|
}
|
|
if !h.loaded {
|
|
return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded."))
|
|
}
|
|
if h.err != nil {
|
|
return lipgloss.NewStyle().Padding(2, 4).
|
|
Render(styleBad.Render(fmt.Sprintf("Error: %v", h.err)))
|
|
}
|
|
if len(h.entries) == 0 {
|
|
return lipgloss.NewStyle().Padding(2, 4).
|
|
Render(styleDim.Render("No log entries found for this period."))
|
|
}
|
|
|
|
// Append "updated X ago" to label so the chart title reflects freshness
|
|
age := time.Since(h.loadedAt).Round(time.Second)
|
|
labelWithAge := fmt.Sprintf("%s — updated %s ago", label, age)
|
|
|
|
tab := viewTab(idx + 1)
|
|
chart := m.renderBarChart(h.buckets, tabBucketSize(tab), labelWithAge, h.stats)
|
|
bottom := lipgloss.JoinHorizontal(lipgloss.Top,
|
|
m.renderPeriodStats(h.stats),
|
|
m.renderTopHosts(h.stats),
|
|
m.renderTopIPs(h.stats),
|
|
m.renderStatusCodes(h.stats),
|
|
)
|
|
return lipgloss.JoinVertical(lipgloss.Left, chart, bottom)
|
|
}
|
|
|
|
// -- bar chart ----------------------------------------------------------------
|
|
|
|
func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, label string, stats *parser.Stats) string {
|
|
if len(buckets) == 0 {
|
|
return styleBorder.Width(m.width - 4).Render(styleDim.Render("No data for this period."))
|
|
}
|
|
|
|
sorted := make([]parser.Bucket, len(buckets))
|
|
copy(sorted, buckets)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Time.Before(sorted[j].Time) })
|
|
|
|
var maxReqs int64
|
|
for _, b := range sorted {
|
|
if b.Requests > maxReqs {
|
|
maxReqs = b.Requests
|
|
}
|
|
}
|
|
|
|
// Pick label format based on bucket size
|
|
timeFmt := "15:04"
|
|
switch {
|
|
case bucketSize >= 24*time.Hour:
|
|
timeFmt = "02 Jan"
|
|
case bucketSize >= 6*time.Hour:
|
|
timeFmt = "Mon 15h"
|
|
}
|
|
|
|
labelW := len(timeFmt) + 1
|
|
countW := len(fmt.Sprintf("%d", maxReqs)) + 2
|
|
barMax := m.width - labelW - countW - 8
|
|
if barMax < 10 {
|
|
barMax = 10
|
|
}
|
|
|
|
title := fmt.Sprintf("Requests / %s — %s — total: %s req, %s",
|
|
bucketSizeLabel(bucketSize), label,
|
|
formatComma(stats.TotalRequests), formatBytes(stats.TotalBytes),
|
|
)
|
|
|
|
rows := []string{styleHeader.Render(title), ""}
|
|
for _, b := range sorted {
|
|
barLen := 0
|
|
if maxReqs > 0 {
|
|
barLen = int(math.Round(float64(b.Requests) / float64(maxReqs) * float64(barMax)))
|
|
}
|
|
bar := strings.Repeat("█", barLen)
|
|
rows = append(rows, fmt.Sprintf("%-*s %s %s",
|
|
labelW, b.Time.Format(timeFmt),
|
|
styleBar.Render(fmt.Sprintf("%-*s", barMax, bar)),
|
|
styleDim.Render(formatComma(b.Requests)),
|
|
))
|
|
}
|
|
|
|
return styleBorder.Width(m.width - 4).Render(strings.Join(rows, "\n"))
|
|
}
|
|
|
|
// -- shared panels ------------------------------------------------------------
|
|
|
|
func (m model) renderPeriodStats(stats *parser.Stats) string {
|
|
return styleBorder.Render(strings.Join([]string{
|
|
styleHeader.Render("Period Stats"),
|
|
"",
|
|
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(stats.TotalRequests))),
|
|
fmt.Sprintf("Transfer : %s", formatBytes(stats.TotalBytes)),
|
|
fmt.Sprintf("Avg resp : %.1fms", stats.AvgDurationMs),
|
|
}, "\n"))
|
|
}
|
|
|
|
func (m model) renderTopHosts(stats *parser.Stats) string {
|
|
rows := []string{styleHeader.Render("Top Hosts"), ""}
|
|
for _, p := range topN(stats.TopHosts, 8) {
|
|
host := p.key
|
|
if len(host) > 30 {
|
|
host = host[:27] + "..."
|
|
}
|
|
rows = append(rows, fmt.Sprintf("%-30s %s", host, styleGood.Render(formatComma(p.val))))
|
|
}
|
|
if len(stats.TopHosts) == 0 {
|
|
rows = append(rows, styleDim.Render("no data"))
|
|
}
|
|
return styleBorder.Render(strings.Join(rows, "\n"))
|
|
}
|
|
|
|
func (m model) renderTopIPs(stats *parser.Stats) string {
|
|
rows := []string{styleHeader.Render("Top IPs"), ""}
|
|
for _, p := range topN(stats.TopIPs, 8) {
|
|
rows = append(rows, fmt.Sprintf("%-20s %s", p.key, styleGood.Render(formatComma(p.val))))
|
|
}
|
|
if len(stats.TopIPs) == 0 {
|
|
rows = append(rows, styleDim.Render("no data"))
|
|
}
|
|
return styleBorder.Render(strings.Join(rows, "\n"))
|
|
}
|
|
|
|
func (m model) renderStatusCodes(stats *parser.Stats) string {
|
|
groups := map[string]int64{"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0}
|
|
for code, n := range stats.StatusCodes {
|
|
switch {
|
|
case code >= 200 && code < 300:
|
|
groups["2xx"] += n
|
|
case code >= 300 && code < 400:
|
|
groups["3xx"] += n
|
|
case code >= 400 && code < 500:
|
|
groups["4xx"] += n
|
|
case code >= 500:
|
|
groups["5xx"] += n
|
|
}
|
|
}
|
|
rows := []string{styleHeader.Render("Status Codes"), ""}
|
|
for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} {
|
|
s := formatComma(groups[g])
|
|
var colored string
|
|
switch g {
|
|
case "2xx":
|
|
colored = styleGood.Render(s)
|
|
case "3xx":
|
|
colored = styleDim.Render(s)
|
|
case "4xx":
|
|
colored = styleWarn.Render(s)
|
|
case "5xx":
|
|
colored = styleBad.Render(s)
|
|
}
|
|
rows = append(rows, fmt.Sprintf("%s : %s", g, colored))
|
|
}
|
|
return styleBorder.Render(strings.Join(rows, "\n"))
|
|
}
|
|
|
|
// -- helpers ------------------------------------------------------------------
|
|
|
|
func formatBytes(b int64) string {
|
|
switch {
|
|
case b >= 1<<30:
|
|
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
|
|
case b >= 1<<20:
|
|
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
|
|
case b >= 1<<10:
|
|
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|
|
|
|
func formatComma(n int64) string {
|
|
s := fmt.Sprintf("%d", n)
|
|
out := ""
|
|
for i, c := range s {
|
|
if i > 0 && (len(s)-i)%3 == 0 {
|
|
out += ","
|
|
}
|
|
out += string(c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type kv struct {
|
|
key string
|
|
val int64
|
|
}
|
|
|
|
func topN(m map[string]int64, n int) []kv {
|
|
pairs := make([]kv, 0, len(m))
|
|
for k, v := range m {
|
|
pairs = append(pairs, kv{k, v})
|
|
}
|
|
sort.Slice(pairs, func(i, j int) bool { return pairs[i].val > pairs[j].val })
|
|
if len(pairs) > n {
|
|
return pairs[:n]
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
func bucketSizeLabel(d time.Duration) string {
|
|
switch {
|
|
case d >= 24*time.Hour:
|
|
return "day"
|
|
case d >= time.Hour:
|
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
}
|
|
}
|
|
|
|
// -- entry point --------------------------------------------------------------
|
|
|
|
func Run(client *sshclient.Client, logPath string) error {
|
|
p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen())
|
|
_, err := p.Run()
|
|
return err
|
|
}
|