From 5a7d97b71632bb3cc89243673d95155fa51d7508 Mon Sep 17 00:00:00 2001 From: Fabrizio Gomez Date: Sat, 9 May 2026 00:46:28 -0600 Subject: [PATCH 1/2] Rounded & colored ping --- internal/tui/tui.go | 110 +++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 51795ea..0b17c10 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "image/color" "time" @@ -176,83 +177,76 @@ func getStatusIndicator(state string) string { } } -// ===== Stats Row ===== -func renderStatRow(label, value string, width int) string { - return lipgloss.NewStyle(). - Width(width). - Render( - labelStyle.Width(20).Render(label+":") + - valueStyle.Render(value), - ) +// ===== Stat Row ===== +func renderStatRow(label, value string) string { + return lipgloss.JoinHorizontal( + lipgloss.Left, + labelStyle.Width(16).Render(label+":"), + valueStyle.Render(value), + ) } -// ===== Main View ===== -func (m model) View() tea.View { - // Status section - var statusText string - switch m.state { - case LoadingState: - statusText = getStatusIndicator("loading") - case ErrorState: - if m.err != nil { - statusText = getStatusIndicator("error") - } - case OnlineState: - statusText = getStatusIndicator("online") +func renderPingRow(ping time.Duration) string { + var color color.Color + switch { + case ping < 50*time.Millisecond: + color = successColor + case ping < 200*time.Millisecond: + color = warningColor + default: + color = errorColor } - // Build status line with stopwatch - statusLine := lipgloss.JoinHorizontal( - lipgloss.Top, - statusText, - // stopwatchStyle.Render(m.activeTime.View()), + return lipgloss.JoinHorizontal( + lipgloss.Left, + labelStyle.Width(16).Render("Ping:"), + lipgloss.NewStyle().Foreground(color).Render(fmt.Sprintf("%v", ping.Round(time.Millisecond))), ) - if m.state != ErrorState { - statusLine = lipgloss.JoinHorizontal(lipgloss.Top, statusText, stopwatchStyle.Render(m.activeTime.View())) - } +} - // Stats panel content - statsContent := lipgloss.JoinVertical( - lipgloss.Left, - renderStatRow("Addr", m.address, 35), - "", // Spacer - renderStatRow("Active", fmt.Sprintf("%d", m.activeConnections), 35), - renderStatRow("Total", fmt.Sprintf("%d", m.totalConnections), 35), - renderStatRow("Local Errors", fmt.Sprintf("%d", m.localServerErrors), 35), - renderStatRow("Ping (5s)", fmt.Sprintf("%v", m.lastPing), 35), +// ===== Main View ===== +func (m model) View() tea.View { + // Status line + statusText := getStatusIndicator(m.state) + statusLine := lipgloss.JoinHorizontal( + lipgloss.Center, + statusText, + stopwatchStyle.Render(m.activeTime.View()), ) - // Combine everything + // Stats panel var body string - if m.state == ErrorState && m.err != nil { - errorPanel := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(errorColor). - Padding(1, 2). - Width(50). - Render( - lipgloss.NewStyle(). - Foreground(errorColor). - Render(fmt.Sprintf("Error: %v", m.err)), - ) - body = lipgloss.JoinVertical(lipgloss.Center, statusLine, errorPanel) + errorContent := lipgloss.NewStyle(). + Foreground(errorColor). + Render(fmt.Sprintf("Error: %v", m.err)) + body = lipgloss.JoinVertical( + lipgloss.Center, + statusLine, + "", + errorContent, + ) } else { - statsPanel := statsPanelStyle.Render(statsContent) - body = lipgloss.JoinHorizontal( - lipgloss.Top, + statsContent := lipgloss.JoinVertical( + lipgloss.Left, + renderStatRow("Address", m.address), + renderStatRow("Active", fmt.Sprintf("%d", m.activeConnections)), + renderStatRow("Total", fmt.Sprintf("%d", m.totalConnections)), + renderStatRow("Errors", fmt.Sprintf("%d", m.localServerErrors)), + renderPingRow(m.lastPing), + ) + body = lipgloss.JoinVertical( + lipgloss.Center, statusLine, - statsPanel, + "", + statsPanelStyle.Render(statsContent), ) } - // Main container mainContent := mainBorderStyle.Render(body) - // Help text helpText := helpStyle.Render("Press q to quit • Press c to copy address") - // Final assembly return tea.NewView(lipgloss.JoinVertical( lipgloss.Center, mainContent, From 9836669904a2420d7c97db18eeb499264d9e5e98 Mon Sep 17 00:00:00 2001 From: Fabrizio Gomez Date: Sat, 9 May 2026 01:45:10 -0600 Subject: [PATCH 2/2] TUI revamp --- cmd/yatun/main.go | 33 ++++++- internal/tui/tui.go | 225 ++++++++++++++++++++++++++------------------ 2 files changed, 166 insertions(+), 92 deletions(-) diff --git a/cmd/yatun/main.go b/cmd/yatun/main.go index 74e04c0..da2b2ea 100644 --- a/cmd/yatun/main.go +++ b/cmd/yatun/main.go @@ -79,6 +79,24 @@ func initializeServerConnection(tuiP *tea.Program, server string) (sess *yamux.S return } +type trafficMonitor struct { + underlying io.ReadWriter + tuiP *tea.Program + streamType tui.TrafficDirection +} + +func (c trafficMonitor) Read(p []byte) (n int, err error) { + n, err = c.underlying.Read(p) + go c.tuiP.Send(tui.TrafficUpdate{ + Direction: c.streamType, + Bytes: n, + }) + return +} +func (c trafficMonitor) Write(p []byte) (n int, err error) { + return c.underlying.Write(p) +} + func serverConnectionLoop(sess *yamux.Session, port *string, tuiP *tea.Program) { // TODO: After initial handshake is done, io.Copy from server (yatun) to internal target server for { @@ -106,17 +124,28 @@ func serverConnectionLoop(sess *yamux.Session, port *string, tuiP *tea.Program) } defer localConn.Close() + streamCopier := trafficMonitor{ + underlying: stream, + tuiP: tuiP, + streamType: tui.Inbound, + } + localConnCopier := trafficMonitor{ + underlying: localConn, + tuiP: tuiP, + streamType: tui.Outbound, + } + wg := sync.WaitGroup{} wg.Go(func() { - io.Copy(stream, localConn) + io.Copy(streamCopier, localConnCopier) localConn.Close() }) wg.Go(func() { - io.Copy(localConn, stream) + io.Copy(localConnCopier, streamCopier) stream.Close() }) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0b17c10..c037ece 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,7 +3,7 @@ package tui import ( "fmt" "image/color" - + "strings" "time" "charm.land/bubbles/v2/stopwatch" @@ -16,6 +16,18 @@ type ServerAddress struct { Addr string } +type TrafficDirection = string + +var ( + Outbound TrafficDirection = "outbound" + Inbound TrafficDirection = "inbound" +) + +type TrafficUpdate struct { + Direction TrafficDirection + Bytes int +} + type stateType = string type SetState struct { State stateType @@ -48,6 +60,9 @@ type model struct { state stateType err error + + inboundTraffic int + outboundTraffic int } func (m model) Init() tea.Cmd { @@ -56,6 +71,12 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case TrafficUpdate: + if msg.Direction == Inbound { + m.inboundTraffic += msg.Bytes + } else { + m.outboundTraffic += msg.Bytes + } case ConnectionState: switch msg { @@ -99,162 +120,186 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // ===== Color Palette ===== var ( - primaryColor = lipgloss.Color("#818CF8") // Indigo - successColor = lipgloss.Color("#34D399") // Emerald - warningColor = lipgloss.Color("#FBBF24") // Amber - errorColor = lipgloss.Color("#F87171") // Red - subtleColor = lipgloss.Color("#6B7280") // Gray - borderColor = lipgloss.Color("#374151") // Dark gray - highlightColor = lipgloss.Color("#60A5FA") // Blue + primaryColor = lipgloss.Color("#818CF8") + successColor = lipgloss.Color("#34D399") + warningColor = lipgloss.Color("#FBBF24") + errorColor = lipgloss.Color("#F87171") + subtleColor = lipgloss.Color("#9CA3AF") + borderColor = lipgloss.Color("#374151") + highlightColor = lipgloss.Color("#60A5FA") + textBright = lipgloss.Color("#F3F4F6") ) -// ===== Status Styles ===== -var ( - statusOnlineStyle = lipgloss.NewStyle(). - Foreground(successColor). - Bold(true). - Padding(0, 1) - - statusLoadingStyle = lipgloss.NewStyle(). - Foreground(warningColor). - Bold(true). - Padding(0, 1) - - statusErrorStyle = lipgloss.NewStyle(). - Foreground(errorColor). - Bold(true). - Padding(0, 1) -) +const panelWidth = 46 -// ===== Panel Styles ===== +// ===== Main Styles ===== var ( mainBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(borderColor). - Padding(1, 2) + Padding(0, 1) - statsPanelStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(borderColor). - Padding(1, 2). - Width(40) + helpStyle = lipgloss.NewStyle(). + Foreground(subtleColor). + Italic(true). + Align(lipgloss.Center). + Padding(0, 2) ) // ===== Text Styles ===== var ( labelStyle = lipgloss.NewStyle(). - Foreground(subtleColor). - SetString("") + Foreground(subtleColor) valueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#E5E7EB")). - Bold(false) + Foreground(textBright) stopwatchStyle = lipgloss.NewStyle(). - Foreground(primaryColor). - Bold(true). + Foreground(subtleColor). Align(lipgloss.Right) - helpStyle = lipgloss.NewStyle(). - Foreground(subtleColor). - Italic(true). - Align(lipgloss.Center). - Padding(1, 2). - MaxWidth(80) + titleStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) + + sectionSepStyle = lipgloss.NewStyle(). + Foreground(borderColor). + Width(panelWidth). + Align(lipgloss.Center) ) // ===== Status Indicator ===== func getStatusIndicator(state string) string { + var dot string switch state { case "online": - return statusOnlineStyle.Render("● Online") + return lipgloss.NewStyle().Foreground(successColor).Bold(true).Render("● Online") case "loading": - return statusLoadingStyle.Render("◐ Loading...") + return lipgloss.NewStyle().Foreground(warningColor).Bold(true).Render("◐ Loading...") case "error": - return statusErrorStyle.Render("✕ Error") + return lipgloss.NewStyle().Foreground(errorColor).Bold(true).Render("✕ Error") default: - return statusLoadingStyle.Render("○ Unknown") + _ = dot + return lipgloss.NewStyle().Foreground(warningColor).Bold(true).Render("○ Unknown") } } -// ===== Stat Row ===== +// ===== Stat Rows ===== func renderStatRow(label, value string) string { - return lipgloss.JoinHorizontal( - lipgloss.Left, - labelStyle.Width(16).Render(label+":"), + return lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Width(11).Align(lipgloss.Left).Render(label), valueStyle.Render(value), ) } -func renderPingRow(ping time.Duration) string { - var color color.Color +func renderTwoStats(k1, v1, k2, v2 string) string { + colW := panelWidth / 2 + col1 := lipgloss.NewStyle().Width(colW).Align(lipgloss.Left).Render( + labelStyle.Width(9).Align(lipgloss.Left).Render(k1) + valueStyle.Render(v1), + ) + col2 := lipgloss.NewStyle().Width(colW).Align(lipgloss.Left).Render( + labelStyle.Width(9).Align(lipgloss.Left).Render(k2) + valueStyle.Render(v2), + ) + return lipgloss.JoinHorizontal(lipgloss.Left, col1, col2) +} + +func renderColoredPing(ping time.Duration) string { + var c color.Color switch { case ping < 50*time.Millisecond: - color = successColor + c = successColor case ping < 200*time.Millisecond: - color = warningColor + c = warningColor default: - color = errorColor + c = errorColor } + return lipgloss.NewStyle().Foreground(c).Render(fmt.Sprintf("%v", ping.Round(time.Millisecond))) - return lipgloss.JoinHorizontal( - lipgloss.Left, - labelStyle.Width(16).Render("Ping:"), - lipgloss.NewStyle().Foreground(color).Render(fmt.Sprintf("%v", ping.Round(time.Millisecond))), +} + +func renderTrafficStats(inbound, outbound int) string { + colW := panelWidth / 2 + col1 := lipgloss.NewStyle().Width(colW).Align(lipgloss.Left).Render( + lipgloss.NewStyle().Foreground(highlightColor).Render("⬇ In ") + valueStyle.Render(formatBytes(inbound)), + ) + col2 := lipgloss.NewStyle().Width(colW).Align(lipgloss.Right).Render( + lipgloss.NewStyle().Foreground(primaryColor).Render("⬆ Out ") + valueStyle.Render(formatBytes(outbound)), ) + return lipgloss.JoinHorizontal(lipgloss.Center, col1, col2) +} + +func formatBytes(b int) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.2f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } // ===== Main View ===== func (m model) View() tea.View { - // Status line - statusText := getStatusIndicator(m.state) - statusLine := lipgloss.JoinHorizontal( - lipgloss.Center, - statusText, - stopwatchStyle.Render(m.activeTime.View()), + headerTitle := titleStyle.Render("yatun") + headerUptime := stopwatchStyle.Render(m.activeTime.View()) + + header := lipgloss.NewStyle().Width(panelWidth).Render( + lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(panelWidth/2).Align(lipgloss.Left).Render(headerTitle), + lipgloss.NewStyle().Width(panelWidth/2).Align(lipgloss.Right).Render(headerUptime), + ), + ) + + statusLine := lipgloss.NewStyle().Width(panelWidth).Align(lipgloss.Center).Render( + getStatusIndicator(m.state), ) - // Stats panel + thinSep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", panelWidth)) + var body string if m.state == ErrorState && m.err != nil { - errorContent := lipgloss.NewStyle(). + errMsg := lipgloss.NewStyle(). Foreground(errorColor). + Bold(true). + Width(panelWidth). + Align(lipgloss.Center). Render(fmt.Sprintf("Error: %v", m.err)) - body = lipgloss.JoinVertical( - lipgloss.Center, + body = lipgloss.JoinVertical(lipgloss.Left, + header, statusLine, "", - errorContent, + errMsg, ) } else { - statsContent := lipgloss.JoinVertical( - lipgloss.Left, + stats := lipgloss.JoinVertical(lipgloss.Left, renderStatRow("Address", m.address), - renderStatRow("Active", fmt.Sprintf("%d", m.activeConnections)), - renderStatRow("Total", fmt.Sprintf("%d", m.totalConnections)), - renderStatRow("Errors", fmt.Sprintf("%d", m.localServerErrors)), - renderPingRow(m.lastPing), + thinSep, + renderTwoStats("Active", fmt.Sprintf("%d", m.activeConnections), "Total", fmt.Sprintf("%d", m.totalConnections)), + renderTwoStats("Ping", renderColoredPing(m.lastPing), "Errors", fmt.Sprintf("%d", m.localServerErrors)), + "", + sectionSepStyle.Render("── Traffic ──"), + renderTrafficStats(m.inboundTraffic, m.outboundTraffic), ) - body = lipgloss.JoinVertical( - lipgloss.Center, + + body = lipgloss.JoinVertical(lipgloss.Left, + header, statusLine, "", - statsPanelStyle.Render(statsContent), + stats, ) } - mainContent := mainBorderStyle.Render(body) + mainContent := mainBorderStyle.Width(panelWidth + 4).Render(body) - helpText := helpStyle.Render("Press q to quit • Press c to copy address") + helpText := helpStyle.Render("q quit • c copy address") - return tea.NewView(lipgloss.JoinVertical( - lipgloss.Center, - mainContent, - helpText, - )) + return tea.NewView(lipgloss.JoinVertical(lipgloss.Center, mainContent, helpText)) } -func intialModel() model { +func initialModel() model { stMod := stopwatch.New(stopwatch.WithInterval(time.Second)) stMod.Start() @@ -269,6 +314,6 @@ func intialModel() model { } func BuildTUI() *tea.Program { - return tea.NewProgram(intialModel()) + return tea.NewProgram(initialModel()) }