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 51795ea..c037ece 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,7 +2,8 @@ package tui import ( "fmt" - + "image/color" + "strings" "time" "charm.land/bubbles/v2/stopwatch" @@ -15,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 @@ -47,6 +60,9 @@ type model struct { state stateType err error + + inboundTraffic int + outboundTraffic int } func (m model) Init() tea.Cmd { @@ -55,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 { @@ -98,169 +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") } } -// ===== 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 Rows ===== +func renderStatRow(label, value string) string { + return lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Width(11).Align(lipgloss.Left).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 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: + c = successColor + case ping < 200*time.Millisecond: + c = warningColor + default: + c = errorColor } + return lipgloss.NewStyle().Foreground(c).Render(fmt.Sprintf("%v", ping.Round(time.Millisecond))) + +} - // Build status line with stopwatch - statusLine := lipgloss.JoinHorizontal( - lipgloss.Top, - statusText, - // stopwatchStyle.Render(m.activeTime.View()), +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)), ) - if m.state != ErrorState { - statusLine = lipgloss.JoinHorizontal(lipgloss.Top, statusText, stopwatchStyle.Render(m.activeTime.View())) + 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]) +} - // 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 { + 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), + ), ) - // Combine everything - var body string + statusLine := lipgloss.NewStyle().Width(panelWidth).Align(lipgloss.Center).Render( + getStatusIndicator(m.state), + ) + + thinSep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", panelWidth)) + 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) + errMsg := lipgloss.NewStyle(). + Foreground(errorColor). + Bold(true). + Width(panelWidth). + Align(lipgloss.Center). + Render(fmt.Sprintf("Error: %v", m.err)) + body = lipgloss.JoinVertical(lipgloss.Left, + header, + statusLine, + "", + errMsg, + ) } else { - statsPanel := statsPanelStyle.Render(statsContent) - body = lipgloss.JoinHorizontal( - lipgloss.Top, + stats := lipgloss.JoinVertical(lipgloss.Left, + renderStatRow("Address", m.address), + 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.Left, + header, statusLine, - statsPanel, + "", + stats, ) } - // Main container - mainContent := mainBorderStyle.Render(body) + mainContent := mainBorderStyle.Width(panelWidth + 4).Render(body) - // Help text - helpText := helpStyle.Render("Press q to quit • Press c to copy address") + helpText := helpStyle.Render("q quit • c copy address") - // Final assembly - 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() @@ -275,6 +314,6 @@ func intialModel() model { } func BuildTUI() *tea.Program { - return tea.NewProgram(intialModel()) + return tea.NewProgram(initialModel()) }