From 2f413b65563df5edc3dd27915a58ec4e2f51dade Mon Sep 17 00:00:00 2001 From: Nikita Lipatov <6stringsohei@gmail.com> Date: Sun, 5 Apr 2026 15:51:58 +0300 Subject: [PATCH 1/4] Show tunnel IPs per protocol in server dashboard --- .../runners/common/runtime_address_info.go | 75 +++++++++++++++---- .../common/runtime_address_info_test.go | 58 ++++++++++++++ .../internal/bubble_tea/runtime_dashboard.go | 68 ++++++++++++----- .../bubble_tea/runtime_dashboard_test.go | 38 ++++++++++ .../ui/tui/runtime_backend_bubbletea.go | 17 +++-- .../ui/tui/runtime_backend_bubbletea_test.go | 10 +++ 6 files changed, 227 insertions(+), 39 deletions(-) diff --git a/src/presentation/runners/common/runtime_address_info.go b/src/presentation/runners/common/runtime_address_info.go index c9b0905c..cb406748 100644 --- a/src/presentation/runners/common/runtime_address_info.go +++ b/src/presentation/runners/common/runtime_address_info.go @@ -4,13 +4,21 @@ import ( "net/netip" clientConfiguration "tungo/infrastructure/PAL/configuration/client" serverConfiguration "tungo/infrastructure/PAL/configuration/server" + "tungo/infrastructure/settings" ) +type RuntimeTunnelAddress struct { + Protocol settings.Protocol + IPv4 netip.Addr + IPv6 netip.Addr +} + type RuntimeAddressInfo struct { - ServerIPv4 netip.Addr - ServerIPv6 netip.Addr - TunnelIPv4 netip.Addr - TunnelIPv6 netip.Addr + ServerIPv4 netip.Addr + ServerIPv6 netip.Addr + TunnelIPv4 netip.Addr + TunnelIPv6 netip.Addr + TunnelAddresses []RuntimeTunnelAddress } func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configuration) RuntimeAddressInfo { @@ -31,12 +39,16 @@ func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configur if activeSettings.IPv6.IsValid() { info.TunnelIPv6 = activeSettings.IPv6 } + if tunnelAddress, ok := newRuntimeTunnelAddress(protocolOrFallback(activeSettings.Protocol, conf.Protocol), activeSettings.IPv4, activeSettings.IPv6); ok { + info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) + } return info } func RuntimeAddressInfoFromServerConfiguration(conf serverConfiguration.Configuration) RuntimeAddressInfo { info := RuntimeAddressInfo{} - for _, s := range conf.EnabledSettings() { + for _, enabledSetting := range enabledProtocolSettings(conf) { + s := enabledSetting.settings if !info.ServerIPv4.IsValid() { if serverIPv4, ok := s.Server.IPv4(); ok { info.ServerIPv4 = serverIPv4 @@ -47,15 +59,52 @@ func RuntimeAddressInfoFromServerConfiguration(conf serverConfiguration.Configur info.ServerIPv6 = serverIPv6 } } - if !info.TunnelIPv4.IsValid() && s.IPv4.IsValid() { - info.TunnelIPv4 = s.IPv4 - } - if !info.TunnelIPv6.IsValid() && s.IPv6.IsValid() { - info.TunnelIPv6 = s.IPv6 - } - if info.ServerIPv4.IsValid() && info.ServerIPv6.IsValid() && info.TunnelIPv4.IsValid() && info.TunnelIPv6.IsValid() { - break + if tunnelAddress, ok := newRuntimeTunnelAddress(protocolOrFallback(s.Protocol, enabledSetting.protocol), s.IPv4, s.IPv6); ok { + info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) + if !info.TunnelIPv4.IsValid() && tunnelAddress.IPv4.IsValid() { + info.TunnelIPv4 = tunnelAddress.IPv4 + } + if !info.TunnelIPv6.IsValid() && tunnelAddress.IPv6.IsValid() { + info.TunnelIPv6 = tunnelAddress.IPv6 + } } } return info } + +type protocolSettings struct { + protocol settings.Protocol + settings settings.Settings +} + +func enabledProtocolSettings(conf serverConfiguration.Configuration) []protocolSettings { + result := make([]protocolSettings, 0, 3) + if conf.EnableTCP { + result = append(result, protocolSettings{protocol: settings.TCP, settings: conf.TCPSettings}) + } + if conf.EnableUDP { + result = append(result, protocolSettings{protocol: settings.UDP, settings: conf.UDPSettings}) + } + if conf.EnableWS { + result = append(result, protocolSettings{protocol: settings.WS, settings: conf.WSSettings}) + } + return result +} + +func newRuntimeTunnelAddress(protocol settings.Protocol, ipv4, ipv6 netip.Addr) (RuntimeTunnelAddress, bool) { + if !ipv4.IsValid() && !ipv6.IsValid() { + return RuntimeTunnelAddress{}, false + } + return RuntimeTunnelAddress{ + Protocol: protocol, + IPv4: ipv4, + IPv6: ipv6, + }, true +} + +func protocolOrFallback(protocol, fallback settings.Protocol) settings.Protocol { + if protocol == settings.UNKNOWN { + return fallback + } + return protocol +} diff --git a/src/presentation/runners/common/runtime_address_info_test.go b/src/presentation/runners/common/runtime_address_info_test.go index 41f2ba63..fee5e88c 100644 --- a/src/presentation/runners/common/runtime_address_info_test.go +++ b/src/presentation/runners/common/runtime_address_info_test.go @@ -35,6 +35,12 @@ func TestRuntimeAddressInfoFromClientConfiguration(t *testing.T) { if got.TunnelIPv6 != netip.MustParseAddr("fd00::2") { t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) } + if len(got.TunnelAddresses) != 1 { + t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) + } + if got.TunnelAddresses[0].Protocol != settings.TCP { + t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) + } } func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { @@ -64,6 +70,55 @@ func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { if got.TunnelIPv6 != netip.MustParseAddr("fd01::1") { t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) } + if len(got.TunnelAddresses) != 1 { + t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) + } + if got.TunnelAddresses[0].Protocol != settings.UDP { + t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) + } +} + +func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledTunnelAddresses(t *testing.T) { + cfg := serverConfiguration.Configuration{ + EnableTCP: true, + EnableUDP: true, + EnableWS: true, + TCPSettings: settings.Settings{ + Protocol: settings.TCP, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + }, + UDPSettings: settings.Settings{ + Protocol: settings.UDP, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.1.1"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }, + WSSettings: settings.Settings{ + Protocol: settings.WS, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.2.1"), + IPv6: netip.MustParseAddr("fd00::3"), + }, + }, + } + + got := RuntimeAddressInfoFromServerConfiguration(cfg) + if len(got.TunnelAddresses) != 3 { + t.Fatalf("expected three tunnel address entries, got %d", len(got.TunnelAddresses)) + } + if got.TunnelAddresses[0].Protocol != settings.TCP || got.TunnelAddresses[0].IPv4 != netip.MustParseAddr("10.0.0.1") || got.TunnelAddresses[0].IPv6 != netip.MustParseAddr("fd00::1") { + t.Fatalf("unexpected TCP tunnel address entry: %+v", got.TunnelAddresses[0]) + } + if got.TunnelAddresses[1].Protocol != settings.UDP || got.TunnelAddresses[1].IPv4 != netip.MustParseAddr("10.0.1.1") || got.TunnelAddresses[1].IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("unexpected UDP tunnel address entry: %+v", got.TunnelAddresses[1]) + } + if got.TunnelAddresses[2].Protocol != settings.WS || got.TunnelAddresses[2].IPv4 != netip.MustParseAddr("10.0.2.1") || got.TunnelAddresses[2].IPv6 != netip.MustParseAddr("fd00::3") { + t.Fatalf("unexpected WS tunnel address entry: %+v", got.TunnelAddresses[2]) + } } func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZeroInfo(t *testing.T) { @@ -75,4 +130,7 @@ func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZer if got.ServerIPv4.IsValid() || got.ServerIPv6.IsValid() || got.TunnelIPv4.IsValid() || got.TunnelIPv6.IsValid() { t.Fatalf("expected zero info when active settings resolution fails, got %+v", got) } + if len(got.TunnelAddresses) != 0 { + t.Fatalf("expected no tunnel address entries when active settings resolution fails, got %d", len(got.TunnelAddresses)) + } } diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go index 1716b83c..cbc660b0 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go @@ -8,6 +8,7 @@ import ( "time" "tungo/infrastructure/settings" "tungo/infrastructure/telemetry/trafficstats" + runnerCommon "tungo/presentation/runners/common" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -30,6 +31,7 @@ type RuntimeDashboardOptions struct { ServerIPv6 netip.Addr TunnelIPv4 netip.Addr TunnelIPv6 netip.Addr + TunnelAddresses []runnerCommon.RuntimeTunnelAddress } type runtimeTickMsg struct { @@ -96,6 +98,7 @@ type RuntimeDashboard struct { serverIPv6 netip.Addr tunnelIPv4 netip.Addr tunnelIPv6 netip.Addr + tunnelAddresses []runnerCommon.RuntimeTunnelAddress } type runtimeDashboardProgram interface { @@ -146,6 +149,7 @@ func NewRuntimeDashboard(ctx context.Context, options RuntimeDashboardOptions, s serverIPv6: options.ServerIPv6, tunnelIPv4: options.TunnelIPv4, tunnelIPv6: options.TunnelIPv6, + tunnelAddresses: options.TunnelAddresses, } if model.preferences.ShowDataplaneGraph { model.recordTrafficSample(trafficstats.SnapshotGlobal()) @@ -398,8 +402,8 @@ func (m RuntimeDashboard) mainView() string { if connectedTo := m.connectedToLine(); connectedTo != "" { body = append(body, connectedTo) } - if tunnelIP := m.tunnelIPLine(); tunnelIP != "" { - body = append(body, tunnelIP) + if tunnelLines := m.tunnelIPLines(); len(tunnelLines) > 0 { + body = append(body, tunnelLines...) } if m.preferences.ShowDataplaneStats || m.preferences.ShowDataplaneGraph { body = append(body, "") @@ -444,31 +448,40 @@ func (m RuntimeDashboard) mainView() string { } func (m RuntimeDashboard) connectedToLine() string { - if !m.serverIPv4.IsValid() && !m.serverIPv6.IsValid() { + parts := formatRuntimeAddressParts(m.serverIPv4, m.serverIPv6) + if parts == "" { return "" } - parts := make([]string, 0, 2) - if m.serverIPv4.IsValid() { - parts = append(parts, "IPv4 "+m.serverIPv4.String()) - } - if m.serverIPv6.IsValid() { - parts = append(parts, "IPv6 "+m.serverIPv6.String()) - } - return "Server IP: " + strings.Join(parts, " | ") + return "Server IP: " + parts } func (m RuntimeDashboard) tunnelIPLine() string { - if !m.tunnelIPv4.IsValid() && !m.tunnelIPv6.IsValid() { + parts := formatRuntimeAddressParts(m.tunnelIPv4, m.tunnelIPv6) + if parts == "" && len(m.tunnelAddresses) == 1 { + parts = formatRuntimeAddressParts(m.tunnelAddresses[0].IPv4, m.tunnelAddresses[0].IPv6) + } + if parts == "" { return "" } - parts := make([]string, 0, 2) - if m.tunnelIPv4.IsValid() { - parts = append(parts, "IPv4 "+m.tunnelIPv4.String()) + return "Tunnel IP: " + parts +} + +func (m RuntimeDashboard) tunnelIPLines() []string { + if m.mode == RuntimeDashboardServer && len(m.tunnelAddresses) > 1 { + lines := []string{"Tunnel IPs:"} + for _, tunnelAddress := range m.tunnelAddresses { + if line := formatRuntimeTunnelAddress(tunnelAddress); line != "" { + lines = append(lines, " "+line) + } + } + if len(lines) > 1 { + return lines + } } - if m.tunnelIPv6.IsValid() { - parts = append(parts, "IPv6 "+m.tunnelIPv6.String()) + if tunnelIP := m.tunnelIPLine(); tunnelIP != "" { + return []string{tunnelIP} } - return "Tunnel IP: " + strings.Join(parts, " | ") + return nil } func (m RuntimeDashboard) protocolLine() string { @@ -478,6 +491,25 @@ func (m RuntimeDashboard) protocolLine() string { return "Protocol: " + m.protocol.String() } +func formatRuntimeAddressParts(ipv4, ipv6 netip.Addr) string { + parts := make([]string, 0, 2) + if ipv4.IsValid() { + parts = append(parts, "IPv4 "+ipv4.String()) + } + if ipv6.IsValid() { + parts = append(parts, "IPv6 "+ipv6.String()) + } + return strings.Join(parts, " | ") +} + +func formatRuntimeTunnelAddress(tunnelAddress runnerCommon.RuntimeTunnelAddress) string { + parts := formatRuntimeAddressParts(tunnelAddress.IPv4, tunnelAddress.IPv6) + if parts == "" { + return "" + } + return tunnelAddress.Protocol.String() + ": " + parts +} + func (m RuntimeDashboard) stopActionLabel() string { if m.mode == RuntimeDashboardClient && m.preferences.AutoConnect { return "Stop (AutoConnect will be disabled)" diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go index 8a2d5c83..2a778450 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go @@ -10,6 +10,7 @@ import ( "time" "tungo/infrastructure/settings" "tungo/infrastructure/telemetry/trafficstats" + runnerCommon "tungo/presentation/runners/common" "unicode/utf8" tea "charm.land/bubbletea/v2" @@ -641,6 +642,43 @@ func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) } } +func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{ + { + Protocol: settings.TCP, + IPv4: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + Protocol: settings.UDP, + IPv4: netip.MustParseAddr("10.0.1.1"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + { + Protocol: settings.WS, + IPv4: netip.MustParseAddr("10.0.2.1"), + IPv6: netip.MustParseAddr("fd00::3"), + }, + }, + }, testSettings()) + + view := m.View().Content + if !strings.Contains(view, "Tunnel IPs:") { + t.Fatalf("expected tunnel addresses section in server main view, got %q", view) + } + if !strings.Contains(view, "TCP: IPv4 10.0.0.1 | IPv6 fd00::1") { + t.Fatalf("expected TCP tunnel line in server main view, got %q", view) + } + if !strings.Contains(view, "UDP: IPv4 10.0.1.1 | IPv6 fd00::2") { + t.Fatalf("expected UDP tunnel line in server main view, got %q", view) + } + if !strings.Contains(view, "WS: IPv4 10.0.2.1 | IPv6 fd00::3") { + t.Fatalf("expected WS tunnel line in server main view, got %q", view) + } +} + func TestRuntimeDashboard_MainView_CanHideStatsAndGraph(t *testing.T) { s := testSettings() p := s.Preferences() diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea.go b/src/presentation/ui/tui/runtime_backend_bubbletea.go index bfe48795..41586720 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea.go @@ -39,14 +39,15 @@ func (b *bubbleTeaRuntimeBackend) disableRuntimeLogCapture() { func (b *bubbleTeaRuntimeBackend) runRuntimeDashboard(ctx context.Context, mode RuntimeMode, options RuntimeUIOptions) (bool, error) { dashboardOptions := bubbleTea.RuntimeDashboardOptions{ - Mode: bubbleTea.RuntimeDashboardClient, - LogFeed: bubbleRuntimeLogFeed(), - ReadyCh: options.ReadyCh, - Protocol: options.Protocol, - ServerIPv4: options.Address.ServerIPv4, - ServerIPv6: options.Address.ServerIPv6, - TunnelIPv4: options.Address.TunnelIPv4, - TunnelIPv6: options.Address.TunnelIPv6, + Mode: bubbleTea.RuntimeDashboardClient, + LogFeed: bubbleRuntimeLogFeed(), + ReadyCh: options.ReadyCh, + Protocol: options.Protocol, + ServerIPv4: options.Address.ServerIPv4, + ServerIPv6: options.Address.ServerIPv6, + TunnelIPv4: options.Address.TunnelIPv4, + TunnelIPv6: options.Address.TunnelIPv6, + TunnelAddresses: options.Address.TunnelAddresses, } if mode == RuntimeModeServer { dashboardOptions.Mode = bubbleTea.RuntimeDashboardServer diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go index ee443df5..ad674276 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go @@ -59,6 +59,12 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { if options.TunnelIPv4 != netip.MustParseAddr("10.0.0.2") { t.Fatalf("expected TunnelIPv4 forwarded, got %v", options.TunnelIPv4) } + if len(options.TunnelAddresses) != 1 { + t.Fatalf("expected one tunnel address entry forwarded, got %d", len(options.TunnelAddresses)) + } + if options.TunnelAddresses[0].Protocol != settings.TCP { + t.Fatalf("expected tunnel address protocol TCP, got %v", options.TunnelAddresses[0].Protocol) + } if options.Protocol != settings.TCP { t.Fatalf("expected protocol forwarded, got %v", options.Protocol) } @@ -68,6 +74,10 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { Address: runnerCommon.RuntimeAddressInfo{ ServerIPv4: netip.MustParseAddr("198.51.100.10"), TunnelIPv4: netip.MustParseAddr("10.0.0.2"), + TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ + Protocol: settings.TCP, + IPv4: netip.MustParseAddr("10.0.0.2"), + }}, }, Protocol: settings.TCP, }) From 745641629f82e8e2aa446f277fe052c8a9793965 Mon Sep 17 00:00:00 2001 From: Nikita Lipatov <6stringsohei@gmail.com> Date: Sun, 5 Apr 2026 16:05:59 +0300 Subject: [PATCH 2/4] refactor(runtime): normalize runtime address model --- .../runners/common/runtime_address_info.go | 92 +++++++++++-------- .../common/runtime_address_info_test.go | 48 +++++----- .../internal/bubble_tea/runtime_dashboard.go | 61 +++++------- .../bubble_tea/runtime_dashboard_test.go | 35 ++++--- .../ui/tui/runtime_backend_bubbletea.go | 5 +- .../ui/tui/runtime_backend_bubbletea_test.go | 19 ++-- src/presentation/ui/tui/runtime_ui_test.go | 8 +- 7 files changed, 142 insertions(+), 126 deletions(-) diff --git a/src/presentation/runners/common/runtime_address_info.go b/src/presentation/runners/common/runtime_address_info.go index cb406748..d46c178c 100644 --- a/src/presentation/runners/common/runtime_address_info.go +++ b/src/presentation/runners/common/runtime_address_info.go @@ -7,17 +7,32 @@ import ( "tungo/infrastructure/settings" ) +type RuntimeAddressPair struct { + IPv4 netip.Addr + IPv6 netip.Addr +} + +func (p RuntimeAddressPair) IsValid() bool { + return p.IPv4.IsValid() || p.IPv6.IsValid() +} + +func (p RuntimeAddressPair) MergeMissing(from RuntimeAddressPair) RuntimeAddressPair { + if !p.IPv4.IsValid() && from.IPv4.IsValid() { + p.IPv4 = from.IPv4 + } + if !p.IPv6.IsValid() && from.IPv6.IsValid() { + p.IPv6 = from.IPv6 + } + return p +} + type RuntimeTunnelAddress struct { Protocol settings.Protocol - IPv4 netip.Addr - IPv6 netip.Addr + Address RuntimeAddressPair } type RuntimeAddressInfo struct { - ServerIPv4 netip.Addr - ServerIPv6 netip.Addr - TunnelIPv4 netip.Addr - TunnelIPv6 netip.Addr + ServerAddress RuntimeAddressPair TunnelAddresses []RuntimeTunnelAddress } @@ -27,19 +42,11 @@ func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configur if err != nil { return info } - if serverIPv4, ok := activeSettings.Server.IPv4(); ok { - info.ServerIPv4 = serverIPv4 - } - if serverIPv6, ok := activeSettings.Server.IPv6(); ok { - info.ServerIPv6 = serverIPv6 - } - if activeSettings.IPv4.IsValid() { - info.TunnelIPv4 = activeSettings.IPv4 - } - if activeSettings.IPv6.IsValid() { - info.TunnelIPv6 = activeSettings.IPv6 - } - if tunnelAddress, ok := newRuntimeTunnelAddress(protocolOrFallback(activeSettings.Protocol, conf.Protocol), activeSettings.IPv4, activeSettings.IPv6); ok { + info.ServerAddress = runtimeAddressPairFromHost(activeSettings.Server) + if tunnelAddress, ok := newRuntimeTunnelAddress( + protocolOrFallback(activeSettings.Protocol, conf.Protocol), + runtimeAddressPairFromAddrs(activeSettings.IPv4, activeSettings.IPv6), + ); ok { info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) } return info @@ -49,24 +56,12 @@ func RuntimeAddressInfoFromServerConfiguration(conf serverConfiguration.Configur info := RuntimeAddressInfo{} for _, enabledSetting := range enabledProtocolSettings(conf) { s := enabledSetting.settings - if !info.ServerIPv4.IsValid() { - if serverIPv4, ok := s.Server.IPv4(); ok { - info.ServerIPv4 = serverIPv4 - } - } - if !info.ServerIPv6.IsValid() { - if serverIPv6, ok := s.Server.IPv6(); ok { - info.ServerIPv6 = serverIPv6 - } - } - if tunnelAddress, ok := newRuntimeTunnelAddress(protocolOrFallback(s.Protocol, enabledSetting.protocol), s.IPv4, s.IPv6); ok { + info.ServerAddress = info.ServerAddress.MergeMissing(runtimeAddressPairFromHost(s.Server)) + if tunnelAddress, ok := newRuntimeTunnelAddress( + protocolOrFallback(s.Protocol, enabledSetting.protocol), + runtimeAddressPairFromAddrs(s.IPv4, s.IPv6), + ); ok { info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) - if !info.TunnelIPv4.IsValid() && tunnelAddress.IPv4.IsValid() { - info.TunnelIPv4 = tunnelAddress.IPv4 - } - if !info.TunnelIPv6.IsValid() && tunnelAddress.IPv6.IsValid() { - info.TunnelIPv6 = tunnelAddress.IPv6 - } } } return info @@ -91,14 +86,31 @@ func enabledProtocolSettings(conf serverConfiguration.Configuration) []protocolS return result } -func newRuntimeTunnelAddress(protocol settings.Protocol, ipv4, ipv6 netip.Addr) (RuntimeTunnelAddress, bool) { - if !ipv4.IsValid() && !ipv6.IsValid() { +func runtimeAddressPairFromHost(host settings.Host) RuntimeAddressPair { + var pair RuntimeAddressPair + if ipv4, ok := host.IPv4(); ok { + pair.IPv4 = ipv4 + } + if ipv6, ok := host.IPv6(); ok { + pair.IPv6 = ipv6 + } + return pair +} + +func runtimeAddressPairFromAddrs(ipv4, ipv6 netip.Addr) RuntimeAddressPair { + return RuntimeAddressPair{ + IPv4: ipv4, + IPv6: ipv6, + } +} + +func newRuntimeTunnelAddress(protocol settings.Protocol, address RuntimeAddressPair) (RuntimeTunnelAddress, bool) { + if !address.IsValid() { return RuntimeTunnelAddress{}, false } return RuntimeTunnelAddress{ Protocol: protocol, - IPv4: ipv4, - IPv6: ipv6, + Address: address, }, true } diff --git a/src/presentation/runners/common/runtime_address_info_test.go b/src/presentation/runners/common/runtime_address_info_test.go index fee5e88c..30b46c80 100644 --- a/src/presentation/runners/common/runtime_address_info_test.go +++ b/src/presentation/runners/common/runtime_address_info_test.go @@ -23,17 +23,11 @@ func TestRuntimeAddressInfoFromClientConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerIPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("ServerIPv4: got %v", got.ServerIPv4) + if got.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("ServerAddress.IPv4: got %v", got.ServerAddress.IPv4) } - if got.ServerIPv6 != netip.MustParseAddr("2001:db8::10") { - t.Fatalf("ServerIPv6: got %v", got.ServerIPv6) - } - if got.TunnelIPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("TunnelIPv4: got %v", got.TunnelIPv4) - } - if got.TunnelIPv6 != netip.MustParseAddr("fd00::2") { - t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) + if got.ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::10") { + t.Fatalf("ServerAddress.IPv6: got %v", got.ServerAddress.IPv6) } if len(got.TunnelAddresses) != 1 { t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) @@ -41,6 +35,12 @@ func TestRuntimeAddressInfoFromClientConfiguration(t *testing.T) { if got.TunnelAddresses[0].Protocol != settings.TCP { t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) } + if got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("TunnelAddresses[0].Address.IPv4: got %v", got.TunnelAddresses[0].Address.IPv4) + } + if got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("TunnelAddresses[0].Address.IPv6: got %v", got.TunnelAddresses[0].Address.IPv6) + } } func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { @@ -58,17 +58,11 @@ func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromServerConfiguration(cfg) - if got.ServerIPv4 != netip.MustParseAddr("203.0.113.10") { - t.Fatalf("ServerIPv4: got %v", got.ServerIPv4) + if got.ServerAddress.IPv4 != netip.MustParseAddr("203.0.113.10") { + t.Fatalf("ServerAddress.IPv4: got %v", got.ServerAddress.IPv4) } - if got.ServerIPv6 != netip.MustParseAddr("2001:db8::20") { - t.Fatalf("ServerIPv6: got %v", got.ServerIPv6) - } - if got.TunnelIPv4 != netip.MustParseAddr("10.1.0.1") { - t.Fatalf("TunnelIPv4: got %v", got.TunnelIPv4) - } - if got.TunnelIPv6 != netip.MustParseAddr("fd01::1") { - t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) + if got.ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") { + t.Fatalf("ServerAddress.IPv6: got %v", got.ServerAddress.IPv6) } if len(got.TunnelAddresses) != 1 { t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) @@ -76,6 +70,12 @@ func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { if got.TunnelAddresses[0].Protocol != settings.UDP { t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) } + if got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.1.0.1") { + t.Fatalf("TunnelAddresses[0].Address.IPv4: got %v", got.TunnelAddresses[0].Address.IPv4) + } + if got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd01::1") { + t.Fatalf("TunnelAddresses[0].Address.IPv6: got %v", got.TunnelAddresses[0].Address.IPv6) + } } func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledTunnelAddresses(t *testing.T) { @@ -110,13 +110,13 @@ func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledTunnelAddre if len(got.TunnelAddresses) != 3 { t.Fatalf("expected three tunnel address entries, got %d", len(got.TunnelAddresses)) } - if got.TunnelAddresses[0].Protocol != settings.TCP || got.TunnelAddresses[0].IPv4 != netip.MustParseAddr("10.0.0.1") || got.TunnelAddresses[0].IPv6 != netip.MustParseAddr("fd00::1") { + if got.TunnelAddresses[0].Protocol != settings.TCP || got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.1") || got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd00::1") { t.Fatalf("unexpected TCP tunnel address entry: %+v", got.TunnelAddresses[0]) } - if got.TunnelAddresses[1].Protocol != settings.UDP || got.TunnelAddresses[1].IPv4 != netip.MustParseAddr("10.0.1.1") || got.TunnelAddresses[1].IPv6 != netip.MustParseAddr("fd00::2") { + if got.TunnelAddresses[1].Protocol != settings.UDP || got.TunnelAddresses[1].Address.IPv4 != netip.MustParseAddr("10.0.1.1") || got.TunnelAddresses[1].Address.IPv6 != netip.MustParseAddr("fd00::2") { t.Fatalf("unexpected UDP tunnel address entry: %+v", got.TunnelAddresses[1]) } - if got.TunnelAddresses[2].Protocol != settings.WS || got.TunnelAddresses[2].IPv4 != netip.MustParseAddr("10.0.2.1") || got.TunnelAddresses[2].IPv6 != netip.MustParseAddr("fd00::3") { + if got.TunnelAddresses[2].Protocol != settings.WS || got.TunnelAddresses[2].Address.IPv4 != netip.MustParseAddr("10.0.2.1") || got.TunnelAddresses[2].Address.IPv6 != netip.MustParseAddr("fd00::3") { t.Fatalf("unexpected WS tunnel address entry: %+v", got.TunnelAddresses[2]) } } @@ -127,7 +127,7 @@ func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZer } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerIPv4.IsValid() || got.ServerIPv6.IsValid() || got.TunnelIPv4.IsValid() || got.TunnelIPv6.IsValid() { + if got.ServerAddress.IsValid() { t.Fatalf("expected zero info when active settings resolution fails, got %+v", got) } if len(got.TunnelAddresses) != 0 { diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go index cbc660b0..5c8320de 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go @@ -3,7 +3,6 @@ package bubble_tea import ( "context" "errors" - "net/netip" "strings" "time" "tungo/infrastructure/settings" @@ -27,10 +26,7 @@ type RuntimeDashboardOptions struct { ServerSupported bool ReadyCh <-chan struct{} Protocol settings.Protocol - ServerIPv4 netip.Addr - ServerIPv6 netip.Addr - TunnelIPv4 netip.Addr - TunnelIPv6 netip.Addr + ServerAddress runnerCommon.RuntimeAddressPair TunnelAddresses []runnerCommon.RuntimeTunnelAddress } @@ -94,10 +90,7 @@ type RuntimeDashboard struct { readyCh <-chan struct{} connected bool protocol settings.Protocol - serverIPv4 netip.Addr - serverIPv6 netip.Addr - tunnelIPv4 netip.Addr - tunnelIPv6 netip.Addr + serverAddress runnerCommon.RuntimeAddressPair tunnelAddresses []runnerCommon.RuntimeTunnelAddress } @@ -145,10 +138,7 @@ func NewRuntimeDashboard(ctx context.Context, options RuntimeDashboardOptions, s readyCh: readyCh, connected: connected, protocol: options.Protocol, - serverIPv4: options.ServerIPv4, - serverIPv6: options.ServerIPv6, - tunnelIPv4: options.TunnelIPv4, - tunnelIPv6: options.TunnelIPv6, + serverAddress: options.ServerAddress, tunnelAddresses: options.TunnelAddresses, } if model.preferences.ShowDataplaneGraph { @@ -448,25 +438,13 @@ func (m RuntimeDashboard) mainView() string { } func (m RuntimeDashboard) connectedToLine() string { - parts := formatRuntimeAddressParts(m.serverIPv4, m.serverIPv6) - if parts == "" { - return "" - } - return "Server IP: " + parts -} - -func (m RuntimeDashboard) tunnelIPLine() string { - parts := formatRuntimeAddressParts(m.tunnelIPv4, m.tunnelIPv6) - if parts == "" && len(m.tunnelAddresses) == 1 { - parts = formatRuntimeAddressParts(m.tunnelAddresses[0].IPv4, m.tunnelAddresses[0].IPv6) - } - if parts == "" { - return "" - } - return "Tunnel IP: " + parts + return formatRuntimeAddressLine("Server IP", m.serverAddress) } func (m RuntimeDashboard) tunnelIPLines() []string { + if len(m.tunnelAddresses) == 0 { + return nil + } if m.mode == RuntimeDashboardServer && len(m.tunnelAddresses) > 1 { lines := []string{"Tunnel IPs:"} for _, tunnelAddress := range m.tunnelAddresses { @@ -478,7 +456,7 @@ func (m RuntimeDashboard) tunnelIPLines() []string { return lines } } - if tunnelIP := m.tunnelIPLine(); tunnelIP != "" { + if tunnelIP := formatRuntimeAddressLine("Tunnel IP", m.tunnelAddresses[0].Address); tunnelIP != "" { return []string{tunnelIP} } return nil @@ -491,22 +469,33 @@ func (m RuntimeDashboard) protocolLine() string { return "Protocol: " + m.protocol.String() } -func formatRuntimeAddressParts(ipv4, ipv6 netip.Addr) string { +func formatRuntimeAddressLine(label string, address runnerCommon.RuntimeAddressPair) string { + parts := formatRuntimeAddressParts(address) + if parts == "" { + return "" + } + return label + ": " + parts +} + +func formatRuntimeAddressParts(address runnerCommon.RuntimeAddressPair) string { parts := make([]string, 0, 2) - if ipv4.IsValid() { - parts = append(parts, "IPv4 "+ipv4.String()) + if address.IPv4.IsValid() { + parts = append(parts, "IPv4 "+address.IPv4.String()) } - if ipv6.IsValid() { - parts = append(parts, "IPv6 "+ipv6.String()) + if address.IPv6.IsValid() { + parts = append(parts, "IPv6 "+address.IPv6.String()) } return strings.Join(parts, " | ") } func formatRuntimeTunnelAddress(tunnelAddress runnerCommon.RuntimeTunnelAddress) string { - parts := formatRuntimeAddressParts(tunnelAddress.IPv4, tunnelAddress.IPv6) + parts := formatRuntimeAddressParts(tunnelAddress.Address) if parts == "" { return "" } + if tunnelAddress.Protocol == settings.UNKNOWN { + return parts + } return tunnelAddress.Protocol.String() + ": " + parts } diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go index 2a778450..8e77cc60 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go @@ -624,11 +624,18 @@ func TestRuntimeDashboard_MainView_ServerAndFooterOff(t *testing.T) { func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) { m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ - Protocol: settings.UDP, - ServerIPv4: netip.MustParseAddr("198.51.100.10"), - ServerIPv6: netip.MustParseAddr("2001:db8::10"), - TunnelIPv4: netip.MustParseAddr("10.0.0.2"), - TunnelIPv6: netip.MustParseAddr("fd00::2"), + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + IPv6: netip.MustParseAddr("2001:db8::10"), + }, + TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ + Protocol: settings.UDP, + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }}, }, testSettings()) view := m.View().Content if !strings.Contains(view, "Protocol: UDP") { @@ -648,18 +655,24 @@ func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *test TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{ { Protocol: settings.TCP, - IPv4: netip.MustParseAddr("10.0.0.1"), - IPv6: netip.MustParseAddr("fd00::1"), + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, }, { Protocol: settings.UDP, - IPv4: netip.MustParseAddr("10.0.1.1"), - IPv6: netip.MustParseAddr("fd00::2"), + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.1.1"), + IPv6: netip.MustParseAddr("fd00::2"), + }, }, { Protocol: settings.WS, - IPv4: netip.MustParseAddr("10.0.2.1"), - IPv6: netip.MustParseAddr("fd00::3"), + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.2.1"), + IPv6: netip.MustParseAddr("fd00::3"), + }, }, }, }, testSettings()) diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea.go b/src/presentation/ui/tui/runtime_backend_bubbletea.go index 41586720..121a7460 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea.go @@ -43,10 +43,7 @@ func (b *bubbleTeaRuntimeBackend) runRuntimeDashboard(ctx context.Context, mode LogFeed: bubbleRuntimeLogFeed(), ReadyCh: options.ReadyCh, Protocol: options.Protocol, - ServerIPv4: options.Address.ServerIPv4, - ServerIPv6: options.Address.ServerIPv6, - TunnelIPv4: options.Address.TunnelIPv4, - TunnelIPv6: options.Address.TunnelIPv6, + ServerAddress: options.Address.ServerAddress, TunnelAddresses: options.Address.TunnelAddresses, } if mode == RuntimeModeServer { diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go index ad674276..ff3ef26e 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go @@ -53,11 +53,8 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { if options.LogFeed != feed { t.Fatal("expected runtime log feed to be forwarded") } - if options.ServerIPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("expected ServerIPv4 forwarded, got %v", options.ServerIPv4) - } - if options.TunnelIPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("expected TunnelIPv4 forwarded, got %v", options.TunnelIPv4) + if options.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("expected ServerAddress.IPv4 forwarded, got %v", options.ServerAddress.IPv4) } if len(options.TunnelAddresses) != 1 { t.Fatalf("expected one tunnel address entry forwarded, got %d", len(options.TunnelAddresses)) @@ -65,6 +62,9 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { if options.TunnelAddresses[0].Protocol != settings.TCP { t.Fatalf("expected tunnel address protocol TCP, got %v", options.TunnelAddresses[0].Protocol) } + if options.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("expected tunnel address IPv4 forwarded, got %v", options.TunnelAddresses[0].Address.IPv4) + } if options.Protocol != settings.TCP { t.Fatalf("expected protocol forwarded, got %v", options.Protocol) } @@ -72,11 +72,14 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { } reconfigure, err := backend.runRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerIPv4: netip.MustParseAddr("198.51.100.10"), - TunnelIPv4: netip.MustParseAddr("10.0.0.2"), + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ Protocol: settings.TCP, - IPv4: netip.MustParseAddr("10.0.0.2"), + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + }, }}, }, Protocol: settings.TCP, diff --git a/src/presentation/ui/tui/runtime_ui_test.go b/src/presentation/ui/tui/runtime_ui_test.go index 98795098..600185f2 100644 --- a/src/presentation/ui/tui/runtime_ui_test.go +++ b/src/presentation/ui/tui/runtime_ui_test.go @@ -49,8 +49,8 @@ func TestRuntimeUI_Wrappers(t *testing.T) { if mode != RuntimeModeServer { t.Fatalf("expected server mode mapping, got %q", mode) } - if options.Address.ServerIPv4 != netip.MustParseAddr("198.51.100.1") { - t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ServerIPv4) + if options.Address.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.1") { + t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ServerAddress.IPv4) } if options.Protocol != settings.UDP { t.Fatalf("expected forwarded protocol UDP, got %v", options.Protocol) @@ -59,7 +59,9 @@ func TestRuntimeUI_Wrappers(t *testing.T) { } quit, err := RunRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerIPv4: netip.MustParseAddr("198.51.100.1"), + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.1"), + }, }, Protocol: settings.UDP, }) From 9815fcd43d36f3ef2e5c7ac028d7e0c6a8bc753a Mon Sep 17 00:00:00 2001 From: Nikita Lipatov <6stringsohei@gmail.com> Date: Sun, 5 Apr 2026 18:26:24 +0300 Subject: [PATCH 3/4] test(runtime): close runtime address coverage gaps --- .../common/runtime_address_info_test.go | 10 ++++++ .../bubble_tea/runtime_dashboard_test.go | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/presentation/runners/common/runtime_address_info_test.go b/src/presentation/runners/common/runtime_address_info_test.go index 30b46c80..4c0b2a2b 100644 --- a/src/presentation/runners/common/runtime_address_info_test.go +++ b/src/presentation/runners/common/runtime_address_info_test.go @@ -134,3 +134,13 @@ func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZer t.Fatalf("expected no tunnel address entries when active settings resolution fails, got %d", len(got.TunnelAddresses)) } } + +func TestNewRuntimeTunnelAddress_InvalidAddressReturnsFalse(t *testing.T) { + got, ok := newRuntimeTunnelAddress(settings.TCP, RuntimeAddressPair{}) + if ok { + t.Fatal("expected invalid address pair to be rejected") + } + if got != (RuntimeTunnelAddress{}) { + t.Fatalf("expected zero tunnel address on invalid input, got %+v", got) + } +} diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go index 8e77cc60..8a0e8ecf 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go @@ -1822,3 +1822,34 @@ func TestWaitForReadyCh_ContextCanceled_ReturnsContextDoneMsg(t *testing.T) { t.Fatalf("expected seq=7, got %d", done.seq) } } + +func TestRuntimeDashboard_TunnelIPLines_InvalidSingleAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ + Protocol: settings.TCP, + }}, + }, testSettings()) + + if got := m.tunnelIPLines(); got != nil { + t.Fatalf("expected nil lines for invalid single tunnel address, got %#v", got) + } +} + +func TestFormatRuntimeTunnelAddress_EmptyAddressReturnsEmpty(t *testing.T) { + if got := formatRuntimeTunnelAddress(runnerCommon.RuntimeTunnelAddress{Protocol: settings.TCP}); got != "" { + t.Fatalf("expected empty line for invalid tunnel address, got %q", got) + } +} + +func TestFormatRuntimeTunnelAddress_UnknownProtocolOmitsProtocolLabel(t *testing.T) { + got := formatRuntimeTunnelAddress(runnerCommon.RuntimeTunnelAddress{ + Protocol: settings.UNKNOWN, + Address: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }) + if got != "IPv4 10.0.0.2 | IPv6 fd00::2" { + t.Fatalf("unexpected unknown-protocol line: %q", got) + } +} From dc2e81a7d0935ceec886ddcef36a793abacdbb47 Mon Sep 17 00:00:00 2001 From: Nikita Lipatov <6stringsohei@gmail.com> Date: Sun, 5 Apr 2026 20:54:33 +0300 Subject: [PATCH 4/4] refactor(runtime): preserve per-protocol server addresses --- .../runners/common/runtime_address_info.go | 49 +++-- .../common/runtime_address_info_test.go | 113 +++++++----- .../internal/bubble_tea/runtime_dashboard.go | 108 +++++++---- .../bubble_tea/runtime_dashboard_test.go | 167 +++++++++++++++--- .../ui/tui/runtime_backend_bubbletea.go | 11 +- .../ui/tui/runtime_backend_bubbletea_test.go | 26 +-- src/presentation/ui/tui/runtime_ui_test.go | 16 +- 7 files changed, 338 insertions(+), 152 deletions(-) diff --git a/src/presentation/runners/common/runtime_address_info.go b/src/presentation/runners/common/runtime_address_info.go index d46c178c..98e12ebb 100644 --- a/src/presentation/runners/common/runtime_address_info.go +++ b/src/presentation/runners/common/runtime_address_info.go @@ -16,24 +16,14 @@ func (p RuntimeAddressPair) IsValid() bool { return p.IPv4.IsValid() || p.IPv6.IsValid() } -func (p RuntimeAddressPair) MergeMissing(from RuntimeAddressPair) RuntimeAddressPair { - if !p.IPv4.IsValid() && from.IPv4.IsValid() { - p.IPv4 = from.IPv4 - } - if !p.IPv6.IsValid() && from.IPv6.IsValid() { - p.IPv6 = from.IPv6 - } - return p -} - -type RuntimeTunnelAddress struct { - Protocol settings.Protocol - Address RuntimeAddressPair +type RuntimeProtocolAddress struct { + Protocol settings.Protocol + ServerAddress RuntimeAddressPair + TunnelAddress RuntimeAddressPair } type RuntimeAddressInfo struct { - ServerAddress RuntimeAddressPair - TunnelAddresses []RuntimeTunnelAddress + ProtocolAddresses []RuntimeProtocolAddress } func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configuration) RuntimeAddressInfo { @@ -42,12 +32,12 @@ func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configur if err != nil { return info } - info.ServerAddress = runtimeAddressPairFromHost(activeSettings.Server) - if tunnelAddress, ok := newRuntimeTunnelAddress( + if protocolAddress, ok := newRuntimeProtocolAddress( protocolOrFallback(activeSettings.Protocol, conf.Protocol), + runtimeAddressPairFromHost(activeSettings.Server), runtimeAddressPairFromAddrs(activeSettings.IPv4, activeSettings.IPv6), ); ok { - info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) + info.ProtocolAddresses = append(info.ProtocolAddresses, protocolAddress) } return info } @@ -56,12 +46,12 @@ func RuntimeAddressInfoFromServerConfiguration(conf serverConfiguration.Configur info := RuntimeAddressInfo{} for _, enabledSetting := range enabledProtocolSettings(conf) { s := enabledSetting.settings - info.ServerAddress = info.ServerAddress.MergeMissing(runtimeAddressPairFromHost(s.Server)) - if tunnelAddress, ok := newRuntimeTunnelAddress( + if protocolAddress, ok := newRuntimeProtocolAddress( protocolOrFallback(s.Protocol, enabledSetting.protocol), + runtimeAddressPairFromHost(s.Server), runtimeAddressPairFromAddrs(s.IPv4, s.IPv6), ); ok { - info.TunnelAddresses = append(info.TunnelAddresses, tunnelAddress) + info.ProtocolAddresses = append(info.ProtocolAddresses, protocolAddress) } } return info @@ -104,13 +94,18 @@ func runtimeAddressPairFromAddrs(ipv4, ipv6 netip.Addr) RuntimeAddressPair { } } -func newRuntimeTunnelAddress(protocol settings.Protocol, address RuntimeAddressPair) (RuntimeTunnelAddress, bool) { - if !address.IsValid() { - return RuntimeTunnelAddress{}, false +func newRuntimeProtocolAddress( + protocol settings.Protocol, + serverAddress RuntimeAddressPair, + tunnelAddress RuntimeAddressPair, +) (RuntimeProtocolAddress, bool) { + if !serverAddress.IsValid() && !tunnelAddress.IsValid() { + return RuntimeProtocolAddress{}, false } - return RuntimeTunnelAddress{ - Protocol: protocol, - Address: address, + return RuntimeProtocolAddress{ + Protocol: protocol, + ServerAddress: serverAddress, + TunnelAddress: tunnelAddress, }, true } diff --git a/src/presentation/runners/common/runtime_address_info_test.go b/src/presentation/runners/common/runtime_address_info_test.go index 4c0b2a2b..35074a38 100644 --- a/src/presentation/runners/common/runtime_address_info_test.go +++ b/src/presentation/runners/common/runtime_address_info_test.go @@ -23,23 +23,23 @@ func TestRuntimeAddressInfoFromClientConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("ServerAddress.IPv4: got %v", got.ServerAddress.IPv4) + if len(got.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(got.ProtocolAddresses)) } - if got.ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::10") { - t.Fatalf("ServerAddress.IPv6: got %v", got.ServerAddress.IPv6) + if got.ProtocolAddresses[0].Protocol != settings.TCP { + t.Fatalf("ProtocolAddresses[0].Protocol: got %v", got.ProtocolAddresses[0].Protocol) } - if len(got.TunnelAddresses) != 1 { - t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv4: got %v", got.ProtocolAddresses[0].ServerAddress.IPv4) } - if got.TunnelAddresses[0].Protocol != settings.TCP { - t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) + if got.ProtocolAddresses[0].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv6: got %v", got.ProtocolAddresses[0].ServerAddress.IPv6) } - if got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("TunnelAddresses[0].Address.IPv4: got %v", got.TunnelAddresses[0].Address.IPv4) + if got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv4: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv4) } - if got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd00::2") { - t.Fatalf("TunnelAddresses[0].Address.IPv6: got %v", got.TunnelAddresses[0].Address.IPv6) + if got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv6: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv6) } } @@ -58,27 +58,27 @@ func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromServerConfiguration(cfg) - if got.ServerAddress.IPv4 != netip.MustParseAddr("203.0.113.10") { - t.Fatalf("ServerAddress.IPv4: got %v", got.ServerAddress.IPv4) + if len(got.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(got.ProtocolAddresses)) } - if got.ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") { - t.Fatalf("ServerAddress.IPv6: got %v", got.ServerAddress.IPv6) + if got.ProtocolAddresses[0].Protocol != settings.UDP { + t.Fatalf("ProtocolAddresses[0].Protocol: got %v", got.ProtocolAddresses[0].Protocol) } - if len(got.TunnelAddresses) != 1 { - t.Fatalf("expected one tunnel address entry, got %d", len(got.TunnelAddresses)) + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("203.0.113.10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv4: got %v", got.ProtocolAddresses[0].ServerAddress.IPv4) } - if got.TunnelAddresses[0].Protocol != settings.UDP { - t.Fatalf("TunnelAddresses[0].Protocol: got %v", got.TunnelAddresses[0].Protocol) + if got.ProtocolAddresses[0].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv6: got %v", got.ProtocolAddresses[0].ServerAddress.IPv6) } - if got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.1.0.1") { - t.Fatalf("TunnelAddresses[0].Address.IPv4: got %v", got.TunnelAddresses[0].Address.IPv4) + if got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.1.0.1") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv4: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv4) } - if got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd01::1") { - t.Fatalf("TunnelAddresses[0].Address.IPv6: got %v", got.TunnelAddresses[0].Address.IPv6) + if got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd01::1") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv6: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv6) } } -func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledTunnelAddresses(t *testing.T) { +func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledProtocolAddresses(t *testing.T) { cfg := serverConfiguration.Configuration{ EnableTCP: true, EnableUDP: true, @@ -107,17 +107,17 @@ func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledTunnelAddre } got := RuntimeAddressInfoFromServerConfiguration(cfg) - if len(got.TunnelAddresses) != 3 { - t.Fatalf("expected three tunnel address entries, got %d", len(got.TunnelAddresses)) + if len(got.ProtocolAddresses) != 3 { + t.Fatalf("expected three protocol address entries, got %d", len(got.ProtocolAddresses)) } - if got.TunnelAddresses[0].Protocol != settings.TCP || got.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.1") || got.TunnelAddresses[0].Address.IPv6 != netip.MustParseAddr("fd00::1") { - t.Fatalf("unexpected TCP tunnel address entry: %+v", got.TunnelAddresses[0]) + if got.ProtocolAddresses[0].Protocol != settings.TCP || got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.1") || got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::1") { + t.Fatalf("unexpected TCP protocol address entry: %+v", got.ProtocolAddresses[0]) } - if got.TunnelAddresses[1].Protocol != settings.UDP || got.TunnelAddresses[1].Address.IPv4 != netip.MustParseAddr("10.0.1.1") || got.TunnelAddresses[1].Address.IPv6 != netip.MustParseAddr("fd00::2") { - t.Fatalf("unexpected UDP tunnel address entry: %+v", got.TunnelAddresses[1]) + if got.ProtocolAddresses[1].Protocol != settings.UDP || got.ProtocolAddresses[1].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.1.1") || got.ProtocolAddresses[1].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("unexpected UDP protocol address entry: %+v", got.ProtocolAddresses[1]) } - if got.TunnelAddresses[2].Protocol != settings.WS || got.TunnelAddresses[2].Address.IPv4 != netip.MustParseAddr("10.0.2.1") || got.TunnelAddresses[2].Address.IPv6 != netip.MustParseAddr("fd00::3") { - t.Fatalf("unexpected WS tunnel address entry: %+v", got.TunnelAddresses[2]) + if got.ProtocolAddresses[2].Protocol != settings.WS || got.ProtocolAddresses[2].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.2.1") || got.ProtocolAddresses[2].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::3") { + t.Fatalf("unexpected WS protocol address entry: %+v", got.ProtocolAddresses[2]) } } @@ -127,20 +127,49 @@ func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZer } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerAddress.IsValid() { - t.Fatalf("expected zero info when active settings resolution fails, got %+v", got) + if len(got.ProtocolAddresses) != 0 { + t.Fatalf("expected no protocol address entries when active settings resolution fails, got %d", len(got.ProtocolAddresses)) } - if len(got.TunnelAddresses) != 0 { - t.Fatalf("expected no tunnel address entries when active settings resolution fails, got %d", len(got.TunnelAddresses)) +} + +func TestRuntimeAddressInfoFromServerConfiguration_PreservesPerProtocolServerAddresses(t *testing.T) { + cfg := serverConfiguration.Configuration{ + EnableTCP: true, + EnableUDP: true, + TCPSettings: settings.Settings{ + Protocol: settings.TCP, + Addressing: settings.Addressing{ + Server: settings.Host{}.WithIPv4(netip.MustParseAddr("198.51.100.10")), + IPv4: netip.MustParseAddr("10.0.0.1"), + }, + }, + UDPSettings: settings.Settings{ + Protocol: settings.UDP, + Addressing: settings.Addressing{ + Server: settings.Host{}.WithIPv6(netip.MustParseAddr("2001:db8::20")), + IPv4: netip.MustParseAddr("10.0.1.1"), + }, + }, + } + + got := RuntimeAddressInfoFromServerConfiguration(cfg) + if len(got.ProtocolAddresses) != 2 { + t.Fatalf("expected two protocol address entries, got %d", len(got.ProtocolAddresses)) + } + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") || got.ProtocolAddresses[0].ServerAddress.IPv6.IsValid() { + t.Fatalf("unexpected TCP server address: %+v", got.ProtocolAddresses[0].ServerAddress) + } + if got.ProtocolAddresses[1].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") || got.ProtocolAddresses[1].ServerAddress.IPv4.IsValid() { + t.Fatalf("unexpected UDP server address: %+v", got.ProtocolAddresses[1].ServerAddress) } } -func TestNewRuntimeTunnelAddress_InvalidAddressReturnsFalse(t *testing.T) { - got, ok := newRuntimeTunnelAddress(settings.TCP, RuntimeAddressPair{}) +func TestNewRuntimeProtocolAddress_InvalidAddressesReturnFalse(t *testing.T) { + got, ok := newRuntimeProtocolAddress(settings.TCP, RuntimeAddressPair{}, RuntimeAddressPair{}) if ok { - t.Fatal("expected invalid address pair to be rejected") + t.Fatal("expected invalid address pairs to be rejected") } - if got != (RuntimeTunnelAddress{}) { - t.Fatalf("expected zero tunnel address on invalid input, got %+v", got) + if got != (RuntimeProtocolAddress{}) { + t.Fatalf("expected zero protocol address on invalid input, got %+v", got) } } diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go index 5c8320de..3eeb5c88 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go @@ -21,13 +21,12 @@ const ( ) type RuntimeDashboardOptions struct { - Mode RuntimeDashboardMode - LogFeed RuntimeLogFeed - ServerSupported bool - ReadyCh <-chan struct{} - Protocol settings.Protocol - ServerAddress runnerCommon.RuntimeAddressPair - TunnelAddresses []runnerCommon.RuntimeTunnelAddress + Mode RuntimeDashboardMode + LogFeed RuntimeLogFeed + ServerSupported bool + ReadyCh <-chan struct{} + Protocol settings.Protocol + ProtocolAddresses []runnerCommon.RuntimeProtocolAddress } type runtimeTickMsg struct { @@ -90,8 +89,7 @@ type RuntimeDashboard struct { readyCh <-chan struct{} connected bool protocol settings.Protocol - serverAddress runnerCommon.RuntimeAddressPair - tunnelAddresses []runnerCommon.RuntimeTunnelAddress + protocolAddresses []runnerCommon.RuntimeProtocolAddress } type runtimeDashboardProgram interface { @@ -125,21 +123,20 @@ func NewRuntimeDashboard(ctx context.Context, options RuntimeDashboardOptions, s } } model := RuntimeDashboard{ - settings: settings, - ctx: ctx, - mode: mode, - serverSupported: options.ServerSupported, - keys: defaultSelectorKeyMap(), - screen: runtimeScreenDataplane, - preferences: settings.Preferences(), - logFeed: options.LogFeed, - logs: newLogViewport(), - tickSeq: 1, - readyCh: readyCh, - connected: connected, - protocol: options.Protocol, - serverAddress: options.ServerAddress, - tunnelAddresses: options.TunnelAddresses, + settings: settings, + ctx: ctx, + mode: mode, + serverSupported: options.ServerSupported, + keys: defaultSelectorKeyMap(), + screen: runtimeScreenDataplane, + preferences: settings.Preferences(), + logFeed: options.LogFeed, + logs: newLogViewport(), + tickSeq: 1, + readyCh: readyCh, + connected: connected, + protocol: options.Protocol, + protocolAddresses: options.ProtocolAddresses, } if model.preferences.ShowDataplaneGraph { model.recordTrafficSample(trafficstats.SnapshotGlobal()) @@ -389,8 +386,8 @@ func (m RuntimeDashboard) mainView() string { if protocol := m.protocolLine(); protocol != "" { body = append(body, protocol) } - if connectedTo := m.connectedToLine(); connectedTo != "" { - body = append(body, connectedTo) + if serverLines := m.serverAddressLines(); len(serverLines) > 0 { + body = append(body, serverLines...) } if tunnelLines := m.tunnelIPLines(); len(tunnelLines) > 0 { body = append(body, tunnelLines...) @@ -437,18 +434,39 @@ func (m RuntimeDashboard) mainView() string { ) } -func (m RuntimeDashboard) connectedToLine() string { - return formatRuntimeAddressLine("Server IP", m.serverAddress) +func (m RuntimeDashboard) serverAddressLines() []string { + if len(m.protocolAddresses) == 0 { + return nil + } + if m.mode == RuntimeDashboardServer && len(m.protocolAddresses) > 1 { + if sharedAddress, ok := sharedServerAddress(m.protocolAddresses); ok { + return []string{formatRuntimeAddressLine("Server IP", sharedAddress)} + } + lines := []string{"Server IPs:"} + for _, protocolAddress := range m.protocolAddresses { + if line := formatRuntimeProtocolAddress(protocolAddress.Protocol, protocolAddress.ServerAddress); line != "" { + lines = append(lines, " "+line) + } + } + if len(lines) > 1 { + return lines + } + return nil + } + if line := formatRuntimeAddressLine("Server IP", m.protocolAddresses[0].ServerAddress); line != "" { + return []string{line} + } + return nil } func (m RuntimeDashboard) tunnelIPLines() []string { - if len(m.tunnelAddresses) == 0 { + if len(m.protocolAddresses) == 0 { return nil } - if m.mode == RuntimeDashboardServer && len(m.tunnelAddresses) > 1 { + if m.mode == RuntimeDashboardServer && len(m.protocolAddresses) > 1 { lines := []string{"Tunnel IPs:"} - for _, tunnelAddress := range m.tunnelAddresses { - if line := formatRuntimeTunnelAddress(tunnelAddress); line != "" { + for _, protocolAddress := range m.protocolAddresses { + if line := formatRuntimeProtocolAddress(protocolAddress.Protocol, protocolAddress.TunnelAddress); line != "" { lines = append(lines, " "+line) } } @@ -456,7 +474,7 @@ func (m RuntimeDashboard) tunnelIPLines() []string { return lines } } - if tunnelIP := formatRuntimeAddressLine("Tunnel IP", m.tunnelAddresses[0].Address); tunnelIP != "" { + if tunnelIP := formatRuntimeAddressLine("Tunnel IP", m.protocolAddresses[0].TunnelAddress); tunnelIP != "" { return []string{tunnelIP} } return nil @@ -488,15 +506,31 @@ func formatRuntimeAddressParts(address runnerCommon.RuntimeAddressPair) string { return strings.Join(parts, " | ") } -func formatRuntimeTunnelAddress(tunnelAddress runnerCommon.RuntimeTunnelAddress) string { - parts := formatRuntimeAddressParts(tunnelAddress.Address) +func formatRuntimeProtocolAddress(protocol settings.Protocol, address runnerCommon.RuntimeAddressPair) string { + parts := formatRuntimeAddressParts(address) if parts == "" { return "" } - if tunnelAddress.Protocol == settings.UNKNOWN { + if protocol == settings.UNKNOWN { return parts } - return tunnelAddress.Protocol.String() + ": " + parts + return protocol.String() + ": " + parts +} + +func sharedServerAddress(protocolAddresses []runnerCommon.RuntimeProtocolAddress) (runnerCommon.RuntimeAddressPair, bool) { + if len(protocolAddresses) == 0 { + return runnerCommon.RuntimeAddressPair{}, false + } + sharedAddress := protocolAddresses[0].ServerAddress + if !sharedAddress.IsValid() { + return runnerCommon.RuntimeAddressPair{}, false + } + for _, protocolAddress := range protocolAddresses[1:] { + if protocolAddress.ServerAddress != sharedAddress { + return runnerCommon.RuntimeAddressPair{}, false + } + } + return sharedAddress, true } func (m RuntimeDashboard) stopActionLabel() string { diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go index 8a0e8ecf..382a22e4 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go @@ -625,13 +625,13 @@ func TestRuntimeDashboard_MainView_ServerAndFooterOff(t *testing.T) { func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) { m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ Protocol: settings.UDP, - ServerAddress: runnerCommon.RuntimeAddressPair{ - IPv4: netip.MustParseAddr("198.51.100.10"), - IPv6: netip.MustParseAddr("2001:db8::10"), - }, - TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ Protocol: settings.UDP, - Address: runnerCommon.RuntimeAddressPair{ + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + IPv6: netip.MustParseAddr("2001:db8::10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ IPv4: netip.MustParseAddr("10.0.0.2"), IPv6: netip.MustParseAddr("fd00::2"), }, @@ -652,24 +652,33 @@ func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *testing.T) { m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ Mode: RuntimeDashboardServer, - TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ { Protocol: settings.TCP, - Address: runnerCommon.RuntimeAddressPair{ + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ IPv4: netip.MustParseAddr("10.0.0.1"), IPv6: netip.MustParseAddr("fd00::1"), }, }, { Protocol: settings.UDP, - Address: runnerCommon.RuntimeAddressPair{ + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ IPv4: netip.MustParseAddr("10.0.1.1"), IPv6: netip.MustParseAddr("fd00::2"), }, }, { Protocol: settings.WS, - Address: runnerCommon.RuntimeAddressPair{ + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ IPv4: netip.MustParseAddr("10.0.2.1"), IPv6: netip.MustParseAddr("fd00::3"), }, @@ -681,6 +690,9 @@ func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *test if !strings.Contains(view, "Tunnel IPs:") { t.Fatalf("expected tunnel addresses section in server main view, got %q", view) } + if !strings.Contains(view, "Server IP: IPv4 198.51.100.10") { + t.Fatalf("expected shared server IP line in server main view, got %q", view) + } if !strings.Contains(view, "TCP: IPv4 10.0.0.1 | IPv6 fd00::1") { t.Fatalf("expected TCP tunnel line in server main view, got %q", view) } @@ -692,6 +704,46 @@ func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *test } } +func TestRuntimeDashboard_MainView_ServerShowsServerAddressesPerProtocolWhenDifferent(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.1"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv6: netip.MustParseAddr("2001:db8::20"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.1.1"), + }, + }, + }, + }, testSettings()) + + view := m.View().Content + if !strings.Contains(view, "Server IPs:") { + t.Fatalf("expected per-protocol server addresses section, got %q", view) + } + if !strings.Contains(view, "TCP: IPv4 198.51.100.10") { + t.Fatalf("expected TCP server line, got %q", view) + } + if !strings.Contains(view, "UDP: IPv6 2001:db8::20") { + t.Fatalf("expected UDP server line, got %q", view) + } + if strings.Contains(view, "Server IP: IPv4 198.51.100.10 | IPv6 2001:db8::20") { + t.Fatalf("unexpected merged server IP line, got %q", view) + } +} + func TestRuntimeDashboard_MainView_CanHideStatsAndGraph(t *testing.T) { s := testSettings() p := s.Preferences() @@ -1825,8 +1877,9 @@ func TestWaitForReadyCh_ContextCanceled_ReturnsContextDoneMsg(t *testing.T) { func TestRuntimeDashboard_TunnelIPLines_InvalidSingleAddressReturnsNil(t *testing.T) { m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ - TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ - Protocol: settings.TCP, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.TCP, + TunnelAddress: runnerCommon.RuntimeAddressPair{}, }}, }, testSettings()) @@ -1835,21 +1888,91 @@ func TestRuntimeDashboard_TunnelIPLines_InvalidSingleAddressReturnsNil(t *testin } } -func TestFormatRuntimeTunnelAddress_EmptyAddressReturnsEmpty(t *testing.T) { - if got := formatRuntimeTunnelAddress(runnerCommon.RuntimeTunnelAddress{Protocol: settings.TCP}); got != "" { - t.Fatalf("expected empty line for invalid tunnel address, got %q", got) +func TestRuntimeDashboard_ServerAddressLines_InvalidSharedAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ + {Protocol: settings.TCP}, + {Protocol: settings.UDP}, + }, + }, testSettings()) + + if got := m.serverAddressLines(); got != nil { + t.Fatalf("expected nil lines for invalid shared server addresses, got %#v", got) + } +} + +func TestRuntimeDashboard_ServerAddressLines_InvalidSingleAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.TCP, + }}, + }, testSettings()) + + if got := m.serverAddressLines(); got != nil { + t.Fatalf("expected nil lines for invalid single server address, got %#v", got) + } +} + +func TestFormatRuntimeProtocolAddress_EmptyAddressReturnsEmpty(t *testing.T) { + if got := formatRuntimeProtocolAddress(settings.TCP, runnerCommon.RuntimeAddressPair{}); got != "" { + t.Fatalf("expected empty line for invalid runtime address, got %q", got) } } -func TestFormatRuntimeTunnelAddress_UnknownProtocolOmitsProtocolLabel(t *testing.T) { - got := formatRuntimeTunnelAddress(runnerCommon.RuntimeTunnelAddress{ - Protocol: settings.UNKNOWN, - Address: runnerCommon.RuntimeAddressPair{ - IPv4: netip.MustParseAddr("10.0.0.2"), - IPv6: netip.MustParseAddr("fd00::2"), - }, +func TestFormatRuntimeProtocolAddress_UnknownProtocolOmitsProtocolLabel(t *testing.T) { + got := formatRuntimeProtocolAddress(settings.UNKNOWN, runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), }) if got != "IPv4 10.0.0.2 | IPv6 fd00::2" { t.Fatalf("unexpected unknown-protocol line: %q", got) } } + +func TestSharedServerAddress_RequiresExactMatch(t *testing.T) { + if _, ok := sharedServerAddress(nil); ok { + t.Fatal("expected empty protocol address list to have no shared server address") + } + + if _, ok := sharedServerAddress([]runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + IPv6: netip.MustParseAddr("2001:db8::20"), + }, + }, + }); ok { + t.Fatal("expected mixed server address pairs to be treated as different") + } + + shared, ok := sharedServerAddress([]runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + }) + if !ok { + t.Fatal("expected identical server address pairs to be shared") + } + if shared != (runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }) { + t.Fatalf("unexpected shared server address: %+v", shared) + } +} diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea.go b/src/presentation/ui/tui/runtime_backend_bubbletea.go index 121a7460..8a25e14f 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea.go @@ -39,12 +39,11 @@ func (b *bubbleTeaRuntimeBackend) disableRuntimeLogCapture() { func (b *bubbleTeaRuntimeBackend) runRuntimeDashboard(ctx context.Context, mode RuntimeMode, options RuntimeUIOptions) (bool, error) { dashboardOptions := bubbleTea.RuntimeDashboardOptions{ - Mode: bubbleTea.RuntimeDashboardClient, - LogFeed: bubbleRuntimeLogFeed(), - ReadyCh: options.ReadyCh, - Protocol: options.Protocol, - ServerAddress: options.Address.ServerAddress, - TunnelAddresses: options.Address.TunnelAddresses, + Mode: bubbleTea.RuntimeDashboardClient, + LogFeed: bubbleRuntimeLogFeed(), + ReadyCh: options.ReadyCh, + Protocol: options.Protocol, + ProtocolAddresses: options.Address.ProtocolAddresses, } if mode == RuntimeModeServer { dashboardOptions.Mode = bubbleTea.RuntimeDashboardServer diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go index ff3ef26e..102dbdd7 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go @@ -53,17 +53,17 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { if options.LogFeed != feed { t.Fatal("expected runtime log feed to be forwarded") } - if options.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("expected ServerAddress.IPv4 forwarded, got %v", options.ServerAddress.IPv4) + if len(options.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry forwarded, got %d", len(options.ProtocolAddresses)) } - if len(options.TunnelAddresses) != 1 { - t.Fatalf("expected one tunnel address entry forwarded, got %d", len(options.TunnelAddresses)) + if options.ProtocolAddresses[0].Protocol != settings.TCP { + t.Fatalf("expected protocol address protocol TCP, got %v", options.ProtocolAddresses[0].Protocol) } - if options.TunnelAddresses[0].Protocol != settings.TCP { - t.Fatalf("expected tunnel address protocol TCP, got %v", options.TunnelAddresses[0].Protocol) + if options.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("expected server IPv4 forwarded, got %v", options.ProtocolAddresses[0].ServerAddress.IPv4) } - if options.TunnelAddresses[0].Address.IPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("expected tunnel address IPv4 forwarded, got %v", options.TunnelAddresses[0].Address.IPv4) + if options.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("expected tunnel IPv4 forwarded, got %v", options.ProtocolAddresses[0].TunnelAddress.IPv4) } if options.Protocol != settings.TCP { t.Fatalf("expected protocol forwarded, got %v", options.Protocol) @@ -72,12 +72,12 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { } reconfigure, err := backend.runRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerAddress: runnerCommon.RuntimeAddressPair{ - IPv4: netip.MustParseAddr("198.51.100.10"), - }, - TunnelAddresses: []runnerCommon.RuntimeTunnelAddress{{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ Protocol: settings.TCP, - Address: runnerCommon.RuntimeAddressPair{ + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ IPv4: netip.MustParseAddr("10.0.0.2"), }, }}, diff --git a/src/presentation/ui/tui/runtime_ui_test.go b/src/presentation/ui/tui/runtime_ui_test.go index 600185f2..3d574c41 100644 --- a/src/presentation/ui/tui/runtime_ui_test.go +++ b/src/presentation/ui/tui/runtime_ui_test.go @@ -49,8 +49,11 @@ func TestRuntimeUI_Wrappers(t *testing.T) { if mode != RuntimeModeServer { t.Fatalf("expected server mode mapping, got %q", mode) } - if options.Address.ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.1") { - t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ServerAddress.IPv4) + if len(options.Address.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(options.Address.ProtocolAddresses)) + } + if options.Address.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.1") { + t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ProtocolAddresses[0].ServerAddress.IPv4) } if options.Protocol != settings.UDP { t.Fatalf("expected forwarded protocol UDP, got %v", options.Protocol) @@ -59,9 +62,12 @@ func TestRuntimeUI_Wrappers(t *testing.T) { } quit, err := RunRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerAddress: runnerCommon.RuntimeAddressPair{ - IPv4: netip.MustParseAddr("198.51.100.1"), - }, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.1"), + }, + }}, }, Protocol: settings.UDP, })