feat: enhance agent dashboard with sidebar functionality and relay tracking
Kim committed
May 14, 2026 at 18:21 UTC
a677b1a0022c61f847c21805f6352d294334ed82
9 files changed
+472
-117
cmd/portal-tunnel/agent/dashboard.go
+368
-47
@@ -22,6 +22,8 @@ import (
22
const (
23
agentDashboardPollInterval = 2 * time.Second
24
agentDashboardMinRelayRows = 5
25
+ agentDashboardSidebarGutter = 2
26
+ agentDashboardSidebarWidth = 32
27
agentDashboardTunnelInputMaxWidth = 80
28
)
29
@@ -47,8 +49,8 @@ type agentDashboardPane int
49
50
const (
51
agentDashboardPaneTunnels agentDashboardPane = iota
50
- agentDashboardPaneRelays
52
agentDashboardPaneSettings
53
+ agentDashboardPaneRelays
54
agentDashboardPaneMultiHop
55
agentDashboardPaneCount
56
)
@@ -73,9 +75,14 @@ type agentDashboardModel struct {
75
width int
76
height int
77
78
+ sidebarScrollX int
79
+ sidebarDragX int
80
+ sidebarDragging bool
81
+
82
selectedTunnelID string
83
selectedRelayURL string
84
activePane agentDashboardPane
85
+ relayAttempts map[string]bool
86
87
routeDraft []string
88
draftTunnelID string
@@ -127,14 +134,18 @@ type agentDashboardView struct {
134
}
135
136
var (
130
- agentDashboardTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
131
- agentDashboardSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81"))
132
- agentDashboardMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
133
- agentDashboardSelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
137
+ agentDashboardSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("45"))
138
+ agentDashboardMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
139
+ agentDashboardRuleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
140
+ agentDashboardHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("250"))
141
+ agentDashboardSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
142
+ agentDashboardBrandStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("93"))
143
+ agentDashboardLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
144
agentDashboardButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("238"))
145
agentDashboardDisabledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
146
agentDashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
147
agentDashboardOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
148
+ agentDashboardPendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
149
agentDashboardInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
150
)
151
@@ -186,6 +197,7 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
197
m.width = msg.Width
198
m.height = msg.Height
199
m.resizeInputs(msg.Width)
200
+ m.clampSidebarScroll()
201
return m, nil
202
case agentDashboardTickMsg:
203
return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
@@ -198,6 +210,8 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210
}
211
m.clampSelection()
212
m.ensureSelectedSettingsDraft()
213
+ m.syncRelayAttempts()
214
+ m.clampSidebarScroll()
215
}
216
return m, nil
217
case agentDashboardActionMsg:
@@ -316,9 +330,26 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
330
case tea.MouseButtonWheelDown:
331
return m.scrollActivePane(1)
332
}
333
+ if m.sidebarDragging {
334
+ switch event.Action {
335
+ case tea.MouseActionMotion:
336
+ m.sidebarScrollX += m.sidebarDragX - event.X
337
+ m.sidebarDragX = event.X
338
+ m.clampSidebarScroll()
339
+ return m, nil
340
+ case tea.MouseActionRelease:
341
+ m.sidebarDragging = false
342
+ return m, nil
343
+ }
344
+ }
345
if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
346
return m, nil
347
}
348
+ if m.mouseInSidebar(event) {
349
+ m.sidebarDragging = true
350
+ m.sidebarDragX = event.X
351
+ return m, nil
352
+ }
353
for _, region := range m.layout().regions {
354
if event.Y == region.y && event.X >= region.x0 && event.X < region.x1 {
355
if region.action == agentDashboardActionSelectPane {
@@ -464,17 +495,35 @@ func (m *agentDashboardModel) setActivePane(pane agentDashboardPane) {
495
}
496
497
func (m agentDashboardModel) scrollActivePane(delta int) (tea.Model, tea.Cmd) {
467
- switch m.activePane {
468
- case agentDashboardPaneTunnels:
469
- m.selectTunnelOffset(delta)
470
- case agentDashboardPaneRelays, agentDashboardPaneMultiHop:
471
- m.selectRelayOffset(delta)
472
- case agentDashboardPaneSettings:
473
- m.focusSettingsField(m.settingsFocus + delta)
474
- }
498
+ m.selectRelayOffset(delta)
499
return m, nil
500
}
501
502
+func (m agentDashboardModel) mouseInSidebar(event tea.MouseEvent) bool {
503
+ mainWidth, gutter, _ := agentDashboardColumnWidths(m.width)
504
+ return event.X >= mainWidth+gutter
505
+}
506
+
507
+func (m *agentDashboardModel) clampSidebarScroll() {
508
+ _, _, sidebarWidth := agentDashboardColumnWidths(m.width)
509
+ m.sidebarScrollX = max(0, min(m.sidebarScrollX, max(0, m.sidebarContentWidth()-sidebarWidth)))
510
+}
511
+
512
+func (m agentDashboardModel) sidebarContentWidth() int {
513
+ configPath := strings.TrimSpace(m.status.ConfigPath)
514
+ if configPath == "" {
515
+ configPath = strings.TrimSpace(m.configPath)
516
+ }
517
+ contentWidth := lipgloss.Width("PORTAL")
518
+ contentWidth = max(contentWidth, agentDashboardMetaWidth(configPath))
519
+ contentWidth = max(contentWidth, agentDashboardMetaWidth(strings.TrimSpace(m.status.ControlAddr)))
520
+ contentWidth = max(contentWidth, agentDashboardMetaWidth(strconv.Itoa(len(m.status.Tunnels))))
521
+ if wallet := strings.TrimSpace(m.status.WalletAddress); wallet != "" {
522
+ contentWidth = max(contentWidth, agentDashboardMetaWidth(wallet))
523
+ }
524
+ return contentWidth
525
+}
526
+
527
func (m *agentDashboardModel) clampSelection() {
528
if len(m.status.Tunnels) == 0 {
529
m.selectedTunnelID = ""
@@ -550,6 +599,86 @@ func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, typ
599
return tunnel, relay, true
600
}
601
602
+func (m *agentDashboardModel) trackRelayAttempt(tunnelID, relayURL string) {
603
+ key := agentDashboardRelayKey(tunnelID, relayURL)
604
+ if key == "" {
605
+ return
606
+ }
607
+ if m.relayAttempts == nil {
608
+ m.relayAttempts = make(map[string]bool)
609
+ }
610
+ m.relayAttempts[key] = false
611
+}
612
+
613
+func (m *agentDashboardModel) clearRelayAttempt(tunnelID, relayURL string) {
614
+ key := agentDashboardRelayKey(tunnelID, relayURL)
615
+ delete(m.relayAttempts, key)
616
+ if len(m.relayAttempts) == 0 {
617
+ m.relayAttempts = nil
618
+ }
619
+}
620
+
621
+func (m *agentDashboardModel) syncRelayAttempts() {
622
+ if len(m.relayAttempts) == 0 {
623
+ return
624
+ }
625
+ seen := make(map[string]struct{})
626
+ for _, tunnel := range m.status.Tunnels {
627
+ for _, relay := range tunnel.Relays {
628
+ key := agentDashboardRelayKey(tunnel.ID, relay.RelayURL)
629
+ if key == "" {
630
+ continue
631
+ }
632
+ seen[key] = struct{}{}
633
+ if _, ok := m.relayAttempts[key]; !ok {
634
+ continue
635
+ }
636
+ if relayDashboardConnected(tunnel, relay) {
637
+ delete(m.relayAttempts, key)
638
+ continue
639
+ }
640
+ if relay.Connecting {
641
+ m.relayAttempts[key] = false
642
+ } else {
643
+ m.relayAttempts[key] = true
644
+ }
645
+ }
646
+ }
647
+ for key := range m.relayAttempts {
648
+ if _, ok := seen[key]; !ok {
649
+ delete(m.relayAttempts, key)
650
+ }
651
+ }
652
+ if len(m.relayAttempts) == 0 {
653
+ m.relayAttempts = nil
654
+ }
655
+}
656
+
657
+func (m agentDashboardModel) relayDashboardFailed(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
658
+ if relayDashboardConnected(tunnel, relay) || relay.Connecting {
659
+ return false
660
+ }
661
+ failed, ok := m.relayAttempts[agentDashboardRelayKey(tunnel.ID, relay.RelayURL)]
662
+ return ok && failed
663
+}
664
+
665
+func (m agentDashboardModel) relayDashboardConnecting(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
666
+ if relayDashboardConnected(tunnel, relay) || relay.Connecting {
667
+ return relay.Connecting
668
+ }
669
+ failed, ok := m.relayAttempts[agentDashboardRelayKey(tunnel.ID, relay.RelayURL)]
670
+ return ok && !failed
671
+}
672
+
673
+func agentDashboardRelayKey(tunnelID, relayURL string) string {
674
+ tunnelID = strings.TrimSpace(tunnelID)
675
+ relayURL = strings.TrimSpace(relayURL)
676
+ if tunnelID == "" || relayURL == "" {
677
+ return ""
678
+ }
679
+ return tunnelID + "\x00" + relayURL
680
+}
681
+
682
func (m agentDashboardModel) updateSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
683
switch msg.String() {
684
case "tab", "down":
@@ -649,10 +778,11 @@ func (m *agentDashboardModel) resizeInputs(width int) {
778
if width <= 0 {
779
width = 88
780
}
652
- availableWidth := width - lipgloss.Width(m.input.Prompt)
781
+ contentWidth, _, _ := agentDashboardColumnWidths(width)
782
+ availableWidth := contentWidth - lipgloss.Width(m.input.Prompt)
783
m.input.Width = max(1, min(agentDashboardTunnelInputMaxWidth, availableWidth))
784
655
- settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, width-13))
785
+ settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, contentWidth-13))
786
for _, input := range []*textinput.Model{
787
&m.settingsMaxRelays,
788
&m.metadataDescription,
@@ -754,6 +884,9 @@ func (m agentDashboardModel) applySettingsEdit() (tea.Model, tea.Cmd) {
884
if !ok {
885
return m, nil
886
}
887
+ if !m.settingsChanged(tunnel) {
888
+ return m, nil
889
+ }
890
maxActiveRelays, err := strconv.Atoi(strings.TrimSpace(m.settingsMaxRelays.Value()))
891
if err != nil || maxActiveRelays <= 0 {
892
m.err = fmt.Errorf("max active relays must be a positive integer")
@@ -810,9 +943,10 @@ func (m agentDashboardModel) connectSelectedRelay() (tea.Model, tea.Cmd) {
943
if !ok {
944
return m, nil
945
}
813
- if relay.Banned || relayDashboardActive(tunnel, relay) {
946
+ if relay.Banned || relayDashboardActive(tunnel, relay) || m.relayDashboardConnecting(tunnel, relay) {
947
return m, nil
948
}
949
+ m.trackRelayAttempt(tunnel.ID, relay.RelayURL)
950
return m, agentDashboardRun(func(ctx context.Context) error {
951
return ConnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
952
})
@@ -826,6 +960,7 @@ func (m agentDashboardModel) disconnectSelectedRelay() (tea.Model, tea.Cmd) {
960
if relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL) {
961
return m, nil
962
}
963
+ m.clearRelayAttempt(tunnel.ID, relay.RelayURL)
964
return m, agentDashboardRun(func(ctx context.Context) error {
965
return DisconnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
966
})
@@ -936,20 +1071,16 @@ func (m agentDashboardModel) layout() agentDashboardView {
1071
if width <= 0 {
1072
width = 88
1073
}
1074
+ mainWidth, gutter, sidebarWidth := agentDashboardColumnWidths(width)
1075
+ main := m.renderMainLayout(mainWidth)
1076
+ sidebar := m.renderSidebar(sidebarWidth, m.height)
1077
+ return agentDashboardJoinHorizontal(main, sidebar, mainWidth, gutter, width, m.height)
1078
+}
1079
+
1080
+func (m agentDashboardModel) renderMainLayout(width int) agentDashboardView {
1081
bodyHeight := defaultDashboardBodyHeight(m.height)
1082
1083
var layout agentDashboardView
942
- layout.addStyled(width, agentDashboardTitleStyle, "Portal Agent "+types.ReleaseVersion)
943
- if configPath := strings.TrimSpace(m.status.ConfigPath); configPath != "" {
944
- layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+configPath, width))
945
- } else if configPath := strings.TrimSpace(m.configPath); configPath != "" {
946
- layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+configPath, width))
947
- }
948
- if controlAddr := strings.TrimSpace(m.status.ControlAddr); controlAddr != "" {
949
- layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Control: "+controlAddr, width))
950
- }
951
- layout.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", min(width, 120))))
952
-
1084
if m.err != nil && m.status.ControlAddr == "" {
1085
layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Agent unavailable: %v", m.err))
1086
layout.addLine("")
@@ -977,6 +1108,29 @@ func (m agentDashboardModel) layout() agentDashboardView {
1108
return layout
1109
}
1110
1111
+func (m agentDashboardModel) renderSidebar(width, height int) agentDashboardView {
1112
+ var pane agentDashboardView
1113
+ pane.addStyled(width, agentDashboardRuleStyle, strings.Repeat("/", width))
1114
+ pane.addStyled(width, agentDashboardBrandStyle, "PORTAL")
1115
+ pane.addStyled(width, agentDashboardMutedStyle, "Agent "+types.ReleaseVersion)
1116
+ pane.addStyled(width, agentDashboardRuleStyle, strings.Repeat("-", width))
1117
+
1118
+ configPath := strings.TrimSpace(m.status.ConfigPath)
1119
+ if configPath == "" {
1120
+ configPath = strings.TrimSpace(m.configPath)
1121
+ }
1122
+ pane.addSidebarTitle(width, "Runtime")
1123
+ pane.addMeta(width, m.sidebarScrollX, "Config", configPath)
1124
+ pane.addMeta(width, m.sidebarScrollX, "Control", strings.TrimSpace(m.status.ControlAddr))
1125
+ pane.addMeta(width, m.sidebarScrollX, "Tunnels", strconv.Itoa(len(m.status.Tunnels)))
1126
+ if wallet := strings.TrimSpace(m.status.WalletAddress); wallet != "" {
1127
+ pane.addMeta(width, m.sidebarScrollX, "Wallet", wallet)
1128
+ }
1129
+
1130
+ pane.clip(height)
1131
+ return pane
1132
+}
1133
+
1134
func (m agentDashboardModel) tunnelsSectionHeight(bodyHeight int) int {
1135
if bodyHeight <= 0 {
1136
return bodyHeight
@@ -1014,7 +1168,7 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1168
pane.addLine(m.input.View())
1169
}
1170
tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
1017
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
1171
+ pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
1172
1173
if len(m.status.Tunnels) == 0 {
1174
pane.addStyled(width, agentDashboardMutedStyle, "no tunnels")
@@ -1047,16 +1201,16 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardV
1201
var pane agentDashboardView
1202
tunnel, ok := m.selectedTunnelStatus()
1203
if !ok {
1050
- pane.addSectionTitle(width, agentDashboardPaneRelays, "Relays", m.activePane == agentDashboardPaneRelays)
1204
+ pane.addSectionTitle(width, agentDashboardPaneSettings, "Settings", m.activePane == agentDashboardPaneSettings)
1205
pane.addStyled(width, agentDashboardMutedStyle, "select a tunnel")
1206
pane.clip(height)
1207
return pane
1208
}
1209
1056
- relayLimit := m.relayRowsForHeight(tunnel, height)
1057
- m.renderRelaysSection(&pane, width, relayLimit, tunnel)
1210
+ m.renderSettingsSection(&pane, width, height, tunnel)
1211
pane.addLine("")
1059
- m.renderSettingsSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
1212
+ relayLimit := m.relayRowsForHeight(tunnel, max(1, height-len(pane.lines)))
1213
+ m.renderRelaysSection(&pane, width, relayLimit, tunnel)
1214
pane.addLine("")
1215
m.renderRouteSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
1216
pane.clip(height)
@@ -1069,8 +1223,7 @@ func (m agentDashboardModel) relayRowsForHeight(tunnel types.AgentTunnelStatus,
1223
}
1224
routeRows := len(m.displayedRoute(tunnel))
1225
routeReserve := min(max(5, routeRows+4), 9)
1072
- settingsReserve := 9
1073
- relayRows := height - routeReserve - settingsReserve - 4
1226
+ relayRows := height - routeReserve - 4
1227
if relayRows < agentDashboardMinRelayRows {
1228
relayRows = min(agentDashboardMinRelayRows, len(tunnel.Relays))
1229
}
@@ -1079,7 +1232,7 @@ func (m agentDashboardModel) relayRowsForHeight(tunnel types.AgentTunnelStatus,
1232
1233
func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width, maxRows int, tunnel types.AgentTunnelStatus) {
1234
relay, hasRelay := m.selectedRelayStatus()
1082
- connectDisabled := !hasRelay || relay.Banned || relayDashboardActive(tunnel, relay)
1235
+ connectDisabled := !hasRelay || relay.Banned || relayDashboardActive(tunnel, relay) || m.relayDashboardConnecting(tunnel, relay)
1236
disconnectDisabled := !hasRelay || relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL)
1237
1238
pane.addSectionTitle(width, agentDashboardPaneRelays, "Relays", m.activePane == agentDashboardPaneRelays)
@@ -1087,7 +1240,7 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
1240
agentDashboardButton{label: "Connect", action: agentDashboardActionConnectRelay, disabled: connectDisabled},
1241
agentDashboardButton{label: "Disconnect", action: agentDashboardActionDisconnectRelay, disabled: disconnectDisabled},
1242
)
1090
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATUS", "FEATURES", "TUNNEL URL")))
1243
+ pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardRelayRow(width, "STATUS", "VERSION", "FEATURES", "TUNNEL URL")))
1244
1245
if len(tunnel.Relays) == 0 {
1246
pane.addStyled(width, agentDashboardMutedStyle, "no relays")
@@ -1104,10 +1257,11 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
1257
relay := tunnel.Relays[i]
1258
line := agentDashboardRelayRow(rowWidth,
1259
m.relayDashboardMode(tunnel, relay),
1260
+ relayDashboardVersion(relay),
1261
relayDashboardFeatures(relay),
1262
relayDashboardURL(relay),
1263
)
1110
- pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, tunnel, relay), agentDashboardActionOpenTunnelURL, tunnel.ID, relay.RelayURL)
1264
+ pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, tunnel, relay, m.relayDashboardFailed(tunnel, relay), m.relayDashboardConnecting(tunnel, relay)), agentDashboardActionOpenTunnelURL, tunnel.ID, relay.RelayURL)
1265
}
1266
}
1267
@@ -1117,8 +1271,13 @@ func (m agentDashboardModel) renderSettingsSection(pane *agentDashboardView, wid
1271
}
1272
startLine := len(pane.lines)
1273
pane.addSectionTitle(width, agentDashboardPaneSettings, "Settings", m.activePane == agentDashboardPaneSettings)
1274
+ applyLabel := "Apply"
1275
+ settingsChanged := m.settingsChanged(tunnel)
1276
+ if m.settingsEditTunnelID == tunnel.ID && !settingsChanged {
1277
+ applyLabel = "Applied"
1278
+ }
1279
pane.addButtons(width,
1121
- agentDashboardButton{label: "Apply", action: agentDashboardActionApplySettings, disabled: m.settingsEditTunnelID != tunnel.ID},
1280
+ agentDashboardButton{label: applyLabel, action: agentDashboardActionApplySettings, disabled: !settingsChanged},
1281
)
1282
if len(pane.lines)-startLine >= height {
1283
return
@@ -1199,6 +1358,25 @@ func (v *agentDashboardView) addStyled(width int, style lipgloss.Style, text str
1358
v.addLine(style.Render(agentDashboardFit(text, width)))
1359
}
1360
1361
+func (v *agentDashboardView) addSidebarTitle(width int, title string) {
1362
+ label := agentDashboardFit(strings.TrimSpace(title), width)
1363
+ line := agentDashboardLabelStyle.Bold(true).Render(label)
1364
+ if ruleWidth := width - lipgloss.Width(label); ruleWidth > 0 {
1365
+ line += agentDashboardRuleStyle.Render(strings.Repeat("-", ruleWidth))
1366
+ }
1367
+ v.addLine(line)
1368
+}
1369
+
1370
+func (v *agentDashboardView) addMeta(width, offset int, label, value string) {
1371
+ value = strings.TrimSpace(value)
1372
+ if value == "" {
1373
+ value = "-"
1374
+ }
1375
+ labelText := agentDashboardLabelStyle.Render(agentDashboardCell(label+":", 9))
1376
+ valueText := agentDashboardMutedStyle.Render(agentDashboardWindow(value, offset, max(1, width-10)))
1377
+ v.addLine(labelText + " " + valueText)
1378
+}
1379
+
1380
func (v *agentDashboardView) addSectionTitle(width int, pane agentDashboardPane, title string, active bool) {
1381
if width <= 0 {
1382
width = 1
@@ -1207,12 +1385,17 @@ func (v *agentDashboardView) addSectionTitle(width int, pane agentDashboardPane,
1385
if active {
1386
style = agentDashboardSelectedStyle
1387
}
1210
- line := agentDashboardFit(title, width)
1388
+ label := agentDashboardFit(" "+title+" ", width)
1389
+ labelWidth := lipgloss.Width(label)
1390
+ line := style.Render(label)
1391
+ if ruleWidth := width - labelWidth; ruleWidth > 0 {
1392
+ line += agentDashboardRuleStyle.Render(strings.Repeat("-", ruleWidth))
1393
+ }
1394
y := len(v.lines)
1212
- v.lines = append(v.lines, style.Render(line))
1395
+ v.lines = append(v.lines, line)
1396
v.regions = append(v.regions, agentDashboardRegion{
1397
x0: 0,
1215
- x1: min(lipgloss.Width(line), width),
1398
+ x1: min(labelWidth, width),
1399
y: y,
1400
action: agentDashboardActionSelectPane,
1401
pane: pane,
@@ -1234,6 +1417,43 @@ func (v *agentDashboardView) addView(child agentDashboardView) {
1417
}
1418
}
1419
1420
+func agentDashboardJoinHorizontal(left, right agentDashboardView, leftWidth, gutter, totalWidth, height int) agentDashboardView {
1421
+ rows := max(len(left.lines), len(right.lines))
1422
+ if height > 0 {
1423
+ rows = height
1424
+ }
1425
+ var out agentDashboardView
1426
+ out.lines = make([]string, 0, rows)
1427
+ gap := strings.Repeat(" ", gutter)
1428
+ rightWidth := max(1, totalWidth-leftWidth-gutter)
1429
+ for i := range rows {
1430
+ leftLine, rightLine := "", ""
1431
+ if i < len(left.lines) {
1432
+ leftLine = left.lines[i]
1433
+ }
1434
+ if i < len(right.lines) {
1435
+ rightLine = right.lines[i]
1436
+ }
1437
+ out.lines = append(out.lines,
1438
+ agentDashboardPadLine(leftLine, leftWidth)+gap+agentDashboardPadLine(rightLine, rightWidth),
1439
+ )
1440
+ }
1441
+ for _, region := range left.regions {
1442
+ if height <= 0 || region.y < height {
1443
+ out.regions = append(out.regions, region)
1444
+ }
1445
+ }
1446
+ for _, region := range right.regions {
1447
+ if height > 0 && region.y >= height {
1448
+ continue
1449
+ }
1450
+ region.x0 += leftWidth + gutter
1451
+ region.x1 += leftWidth + gutter
1452
+ out.regions = append(out.regions, region)
1453
+ }
1454
+ return out
1455
+}
1456
+
1457
func (v *agentDashboardView) addTunnelRow(width, rowWidth int, tunnel types.AgentTunnelStatus, selected bool) {
1458
if width <= 0 {
1459
width = 1
@@ -1350,6 +1570,27 @@ func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButto
1570
return lines, regions
1571
}
1572
1573
+func agentDashboardColumnWidths(width int) (int, int, int) {
1574
+ if width <= 0 {
1575
+ width = 88
1576
+ }
1577
+ gutter := agentDashboardSidebarGutter
1578
+ if width <= gutter+2 {
1579
+ gutter = 0
1580
+ }
1581
+ sidebarWidth := min(agentDashboardSidebarWidth, max(1, width-gutter-1))
1582
+ mainWidth := max(1, width-sidebarWidth-gutter)
1583
+ return mainWidth, gutter, sidebarWidth
1584
+}
1585
+
1586
+func agentDashboardMetaWidth(value string) int {
1587
+ value = strings.TrimSpace(value)
1588
+ if value == "" {
1589
+ value = "-"
1590
+ }
1591
+ return 10 + lipgloss.Width(value)
1592
+}
1593
+
1594
func defaultDashboardBodyHeight(height int) int {
1595
bodyHeight := height - 8
1596
if height <= 0 {
@@ -1365,6 +1606,8 @@ func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
1606
switch strings.ToLower(strings.TrimSpace(state)) {
1607
case "running":
1608
return agentDashboardOKStyle
1609
+ case "starting":
1610
+ return agentDashboardPendingStyle
1611
case "error":
1612
return agentDashboardErrorStyle
1613
default:
@@ -1372,16 +1615,22 @@ func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
1615
}
1616
}
1617
1375
-func agentDashboardRelayStyle(selected bool, tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) lipgloss.Style {
1618
+func agentDashboardRelayStyle(selected bool, tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus, failed, connecting bool) lipgloss.Style {
1619
if selected {
1620
return agentDashboardSelectedStyle
1621
}
1622
if relay.Banned {
1623
return agentDashboardErrorStyle
1624
}
1625
+ if failed {
1626
+ return agentDashboardErrorStyle
1627
+ }
1628
if relayDashboardConnected(tunnel, relay) {
1629
return agentDashboardOKStyle
1630
}
1631
+ if connecting {
1632
+ return agentDashboardPendingStyle
1633
+ }
1634
return agentDashboardMutedStyle
1635
}
1636
@@ -1393,6 +1642,27 @@ func relayDashboardConnected(tunnel types.AgentTunnelStatus, relay types.AgentRe
1642
return relay.PublicURL != "" || slices.Contains(tunnel.MultiHop, relay.RelayURL)
1643
}
1644
1645
+func (m agentDashboardModel) settingsChanged(tunnel types.AgentTunnelStatus) bool {
1646
+ if m.settingsEditTunnelID != tunnel.ID {
1647
+ return false
1648
+ }
1649
+ maxRelays, err := strconv.Atoi(strings.TrimSpace(m.settingsMaxRelays.Value()))
1650
+ if err != nil {
1651
+ return true
1652
+ }
1653
+ hide, err := strconv.ParseBool(utils.StringOrDefault(strings.TrimSpace(m.metadataHide.Value()), "false"))
1654
+ if err != nil {
1655
+ return true
1656
+ }
1657
+ metadata := tunnel.Metadata
1658
+ return maxRelays != tunnel.MaxActiveRelays ||
1659
+ strings.TrimSpace(m.metadataDescription.Value()) != strings.TrimSpace(metadata.Description) ||
1660
+ !slices.Equal(utils.SplitCSV(m.metadataTags.Value()), metadata.Tags) ||
1661
+ strings.TrimSpace(m.metadataOwner.Value()) != strings.TrimSpace(metadata.Owner) ||
1662
+ strings.TrimSpace(m.metadataThumbnail.Value()) != strings.TrimSpace(metadata.Thumbnail) ||
1663
+ hide != metadata.Hide
1664
+}
1665
+
1666
func relayDashboardFeatures(relay types.AgentRelayStatus) string {
1667
var features []string
1668
if relay.SupportsOverlay {
@@ -1410,6 +1680,13 @@ func relayDashboardFeatures(relay types.AgentRelayStatus) string {
1680
return strings.Join(features, ",")
1681
}
1682
1683
+func relayDashboardVersion(relay types.AgentRelayStatus) string {
1684
+ if version := strings.TrimSpace(relay.Version); version != "" {
1685
+ return version
1686
+ }
1687
+ return "-"
1688
+}
1689
+
1690
func relayDashboardURL(relay types.AgentRelayStatus) string {
1691
if publicURL := strings.TrimSpace(relay.PublicURL); publicURL != "" {
1692
return publicURL
@@ -1419,8 +1696,12 @@ func relayDashboardURL(relay types.AgentRelayStatus) string {
1696
1697
func (m agentDashboardModel) relayDashboardMode(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) string {
1698
var modes []string
1422
- if relay.PublicURL != "" || relay.Connecting {
1699
+ if relay.PublicURL != "" {
1700
modes = append(modes, "direct")
1701
+ } else if relay.Connecting || m.relayDashboardConnecting(tunnel, relay) {
1702
+ modes = append(modes, "connecting...")
1703
+ } else if m.relayDashboardFailed(tunnel, relay) {
1704
+ modes = append(modes, "failed")
1705
}
1706
for i, relayURL := range tunnel.MultiHop {
1707
if relayURL != relay.RelayURL {
@@ -1495,19 +1776,21 @@ func agentDashboardRelayWindow(selected, total, rows int) (int, int) {
1776
return start, start + rows
1777
}
1778
1498
-func agentDashboardRelayRow(width int, mode, features, displayURL string) string {
1779
+func agentDashboardRelayRow(width int, mode, version, features, displayURL string) string {
1780
if width < 28 {
1781
return agentDashboardFit(mode+" "+displayURL, width)
1782
}
1783
if width < 56 {
1503
- modeW := 16
1784
+ modeW := 13
1785
return agentDashboardCell(mode, modeW) + " " +
1786
agentDashboardFit(displayURL, width-modeW-1)
1787
}
1507
- modeW := 16
1788
+ modeW := 13
1789
+ versionW := 8
1790
featuresW := 15
1509
- relayW := max(1, width-modeW-featuresW-2)
1791
+ relayW := max(1, width-modeW-versionW-featuresW-3)
1792
return agentDashboardCell(mode, modeW) + " " +
1793
+ agentDashboardCell(version, versionW) + " " +
1794
agentDashboardCell(features, featuresW) + " " +
1795
agentDashboardFit(displayURL, relayW)
1796
}
@@ -1572,6 +1855,44 @@ func agentDashboardFit(value string, width int) string {
1855
return out.String() + "~"
1856
}
1857
1858
+func agentDashboardWindow(value string, offset, width int) string {
1859
+ value = strings.ReplaceAll(strings.TrimSpace(value), "\t", " ")
1860
+ if value == "" || width <= 0 {
1861
+ return ""
1862
+ }
1863
+ offset = max(0, offset)
1864
+ if offset == 0 && lipgloss.Width(value) <= width {
1865
+ return value
1866
+ }
1867
+ var out strings.Builder
1868
+ skipped := 0
1869
+ used := 0
1870
+ for _, r := range value {
1871
+ cellWidth := lipgloss.Width(string(r))
1872
+ if skipped+cellWidth <= offset {
1873
+ skipped += cellWidth
1874
+ continue
1875
+ }
1876
+ if skipped < offset {
1877
+ skipped += cellWidth
1878
+ continue
1879
+ }
1880
+ if used+cellWidth > width {
1881
+ break
1882
+ }
1883
+ out.WriteRune(r)
1884
+ used += cellWidth
1885
+ }
1886
+ return out.String()
1887
+}
1888
+
1889
+func agentDashboardPadLine(line string, width int) string {
1890
+ if pad := width - lipgloss.Width(line); pad > 0 {
1891
+ return line + strings.Repeat(" ", pad)
1892
+ }
1893
+ return line
1894
+}
1895
+
1896
func agentDashboardCell(value string, width int) string {
1897
value = agentDashboardFit(value, width)
1898
if lipgloss.Width(value) >= width {
cmd/portal-tunnel/agent/manager.go
+3
-1
@@ -444,7 +444,9 @@ type managedTunnel struct {
444
}
445
446
func newTunnel(cfg TunnelConfig) *managedTunnel {
447
- return &managedTunnel{cfg: cfg}
447
+ return &managedTunnel{
448
+ cfg: cfg,
449
+ }
450
}
451
452
func (t *managedTunnel) Start(parent context.Context) {
cmd/portal-tunnel/installer/install.sh
+12
-4
@@ -60,7 +60,12 @@ fetch_url() {
60
TMPDIR="${TMPDIR:-/tmp}"
61
WORKDIR="$(mktemp -d "$TMPDIR/portal-install.XXXXXX" 2>/dev/null || mktemp -d -t portal-install)"
62
BIN_PATH="$WORKDIR/portal"
63
-cleanup() { rm -rf "$WORKDIR"; }
63
+cleanup() {
64
+ if [ -n "${TMP_INSTALL_PATH:-}" ]; then
65
+ rm -f "$TMP_INSTALL_PATH"
66
+ fi
67
+ rm -rf "$WORKDIR"
68
+}
69
trap cleanup EXIT INT TERM
70
71
echo "Downloading portal ($PORTAL_OS/$PORTAL_ARCH)..." >&2
@@ -120,12 +125,15 @@ INSTALL_PATH="$(pick_install_path)" || {
125
exit 1
126
}
127
123
-cp "$BIN_PATH" "$INSTALL_PATH"
124
-chmod +x "$INSTALL_PATH"
128
+INSTALL_DIR="$(dirname "$INSTALL_PATH")"
129
+TMP_INSTALL_PATH="$(mktemp "$INSTALL_DIR/.portal.tmp.XXXXXX")"
130
+cp "$BIN_PATH" "$TMP_INSTALL_PATH"
131
+chmod +x "$TMP_INSTALL_PATH"
132
+mv -f "$TMP_INSTALL_PATH" "$INSTALL_PATH"
133
+TMP_INSTALL_PATH=""
134
135
echo "Installed portal to $INSTALL_PATH" >&2
136
128
-INSTALL_DIR="$(dirname "$INSTALL_PATH")"
137
case ":$PATH:" in
138
*":$INSTALL_DIR:"*) ;;
139
*)
cmd/portal-tunnel/installer/update.go
+22
-4
@@ -204,15 +204,33 @@ func replaceBinaryUnix(srcPath, dstPath string) error {
204
}
205
defer func() { _ = src.Close() }()
206
207
- dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
207
+ dstDir := filepath.Dir(dstPath)
208
+ tmp, err := os.CreateTemp(dstDir, "."+filepath.Base(dstPath)+".update-*")
209
if err != nil {
209
- return fmt.Errorf("failed to open destination: %w", err)
210
+ return fmt.Errorf("failed to create replacement file: %w", err)
211
}
211
- defer func() { _ = dst.Close() }()
212
+ tmpPath := tmp.Name()
213
+ defer func() { _ = os.Remove(tmpPath) }()
214
213
- if _, err := io.Copy(dst, src); err != nil {
215
+ if _, err := io.Copy(tmp, src); err != nil {
216
+ _ = tmp.Close()
217
return fmt.Errorf("failed to copy binary: %w", err)
218
}
219
+ if err := tmp.Chmod(0755); err != nil {
220
+ _ = tmp.Close()
221
+ return fmt.Errorf("failed to set replacement permissions: %w", err)
222
+ }
223
+ if err := tmp.Sync(); err != nil {
224
+ _ = tmp.Close()
225
+ return fmt.Errorf("failed to sync replacement binary: %w", err)
226
+ }
227
+ if err := tmp.Close(); err != nil {
228
+ return fmt.Errorf("failed to close replacement binary: %w", err)
229
+ }
230
+ if err := os.Rename(tmpPath, dstPath); err != nil {
231
+ return fmt.Errorf("failed to replace destination: %w", err)
232
+ }
233
+ tmpPath = ""
234
return nil
235
}
236
sdk/api_client.go
+2
@@ -78,6 +78,8 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
78
return fmt.Errorf("%w: relay sdk protocol version mismatch: relay=%q client=%q", errRelayIncompatible, protocolVersion, types.SDKVersion)
79
}
80
81
+ l.releaseVersion = strings.TrimSpace(domainResp.ReleaseVersion)
82
+
83
l.httpClient = httpClient
84
l.httpTransport = httpTransport
85
l.tlsConfig = tlsConfig
sdk/expose.go
+58
-54
@@ -76,15 +76,6 @@ func (e *Exposure) config() ExposeConfig {
76
return e.cfg.clone()
77
}
78
79
-func (e *Exposure) updateCfg(update func(*ExposeConfig) error) error {
80
- if e == nil {
81
- return errors.New("exposure config is not initialized")
82
- }
83
- e.cfgMu.Lock()
84
- defer e.cfgMu.Unlock()
85
- return update(&e.cfg)
86
-}
87
-
79
func (e *Exposure) metadata() types.LeaseMetadata {
80
if e == nil {
81
return types.LeaseMetadata{}
@@ -229,14 +220,11 @@ func (e *Exposure) AddRelay(relayURL string) error {
220
return errors.New("exposure relay set is not initialized")
221
}
222
232
- if err := e.updateCfg(func(cfg *ExposeConfig) error {
233
- if !slices.Contains(cfg.RelayURLs, relayURL) {
234
- cfg.RelayURLs = append(append([]string(nil), cfg.RelayURLs...), relayURL)
235
- }
236
- return nil
237
- }); err != nil {
238
- return err
223
+ e.cfgMu.Lock()
224
+ if !slices.Contains(e.cfg.RelayURLs, relayURL) {
225
+ e.cfg.RelayURLs = append(append([]string(nil), e.cfg.RelayURLs...), relayURL)
226
}
227
+ e.cfgMu.Unlock()
228
229
e.relaySet.AllowRelayURL(relayURL)
230
e.relaySet.AddBootstrapRelayURL(relayURL)
@@ -257,21 +245,19 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
245
return errors.New("exposure relay set is not initialized")
246
}
247
260
- if err := e.updateCfg(func(cfg *ExposeConfig) error {
261
- if slices.Contains(cfg.MultiHop, relayURL) {
262
- return errors.New("relay is part of the multi-hop route; clear multi-hop first")
263
- }
264
- nextRelays := make([]string, 0, len(cfg.RelayURLs))
265
- for _, existing := range cfg.RelayURLs {
266
- if existing != relayURL {
267
- nextRelays = append(nextRelays, existing)
268
- }
248
+ e.cfgMu.Lock()
249
+ if slices.Contains(e.cfg.MultiHop, relayURL) {
250
+ e.cfgMu.Unlock()
251
+ return errors.New("relay is part of the multi-hop route; clear multi-hop first")
252
+ }
253
+ nextRelays := make([]string, 0, len(e.cfg.RelayURLs))
254
+ for _, existing := range e.cfg.RelayURLs {
255
+ if existing != relayURL {
256
+ nextRelays = append(nextRelays, existing)
257
}
270
- cfg.RelayURLs = nextRelays
271
- return nil
272
- }); err != nil {
273
- return err
258
}
259
+ e.cfg.RelayURLs = nextRelays
260
+ e.cfgMu.Unlock()
261
262
e.relaySet.DeactivateRelayURL(relayURL)
263
e.relaySet.RemoveBootstrapRelayURL(relayURL)
@@ -309,13 +295,10 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
295
e.relaySet.AddBootstrapRelayURL(relayURL)
296
}
297
312
- if err := e.updateCfg(func(cfg *ExposeConfig) error {
313
- cfg.MultiHop = append([]string(nil), multiHop...)
314
- cfg.MultiHopDepth = 0
315
- return nil
316
- }); err != nil {
317
- return err
318
- }
298
+ e.cfgMu.Lock()
299
+ e.cfg.MultiHop = append([]string(nil), multiHop...)
300
+ e.cfg.MultiHopDepth = 0
301
+ e.cfgMu.Unlock()
302
return e.reconcileRelayListeners(false)
303
}
304
@@ -324,12 +307,9 @@ func (e *Exposure) UpdateMetadata(metadata types.LeaseMetadata) error {
307
return net.ErrClosed
308
}
309
327
- if err := e.updateCfg(func(cfg *ExposeConfig) error {
328
- cfg.Metadata = metadata.Copy()
329
- return nil
330
- }); err != nil {
331
- return err
332
- }
310
+ e.cfgMu.Lock()
311
+ e.cfg.Metadata = metadata.Copy()
312
+ e.cfgMu.Unlock()
313
return nil
314
}
315
@@ -341,14 +321,10 @@ func (e *Exposure) UpdateMaxActiveRelays(maxActiveRelays int) error {
321
return net.ErrClosed
322
}
323
344
- changed := false
345
- if err := e.updateCfg(func(cfg *ExposeConfig) error {
346
- changed = cfg.MaxActiveRelays != maxActiveRelays
347
- cfg.MaxActiveRelays = maxActiveRelays
348
- return nil
349
- }); err != nil {
350
- return err
351
- }
324
+ e.cfgMu.Lock()
325
+ changed := e.cfg.MaxActiveRelays != maxActiveRelays
326
+ e.cfg.MaxActiveRelays = maxActiveRelays
327
+ e.cfgMu.Unlock()
328
if !changed {
329
return nil
330
}
@@ -416,13 +392,16 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
392
if listener.relayURL != nil {
393
relayURL = listener.relayURL.String()
394
}
395
+ explicit := slices.Contains(cfg.RelayURLs, relayURL)
396
snap := types.AgentRelayStatus{
397
RelayURL: relayURL,
421
- Explicit: slices.Contains(cfg.RelayURLs, relayURL),
422
- Connecting: true,
398
+ Version: listener.releaseVersion,
399
+ Explicit: explicit,
400
+ Connecting: explicit || len(listener.multiHop) > 0,
401
}
402
if lease, ok := listener.leaseSnapshot(); ok {
403
snap.PublicURL = listener.publicURLForLease(lease)
404
+ snap.Connecting = snap.PublicURL == ""
405
}
406
if relayURL != "" {
407
relayByURL[relayURL] = snap
@@ -773,7 +752,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
752
listenerMultiHop = append([]string(nil), multiHop...)
753
}
754
retryCount := 10
776
- if len(listenerMultiHop) > 0 || slices.Contains(cfg.RelayURLs, relayURL) {
755
+ if len(listenerMultiHop) > 0 {
756
retryCount = 0
757
}
758
listener, err := newListener(context.Background(), relayURL, listenerConfig{
@@ -781,7 +760,9 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
760
UDPEnabled: cfg.UDPEnabled,
761
TCPEnabled: cfg.TCPEnabled,
762
BanMITM: cfg.BanMITM,
784
- Metadata: e.metadata,
763
+ Metadata: func() types.LeaseMetadata {
764
+ return e.metadata()
765
+ },
766
MultiHop: listenerMultiHop,
767
RetryCount: retryCount,
768
relaySet: e.relaySet,
@@ -863,11 +844,34 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) {
844
}()
845
}
846
defer func() {
847
+ removed := false
848
e.mu.Lock()
849
if current, ok := e.relayListeners[relayURL]; ok && current == listener {
850
delete(e.relayListeners, relayURL)
851
+ removed = true
852
}
853
e.mu.Unlock()
854
+ if !removed || e.closed() {
855
+ return
856
+ }
857
+
858
+ removedExplicit := false
859
+ e.cfgMu.Lock()
860
+ next := e.cfg.RelayURLs[:0]
861
+ for _, existing := range e.cfg.RelayURLs {
862
+ if existing == relayURL {
863
+ removedExplicit = true
864
+ continue
865
+ }
866
+ next = append(next, existing)
867
+ }
868
+ e.cfg.RelayURLs = next
869
+ e.cfgMu.Unlock()
870
+
871
+ if removedExplicit && e.relaySet != nil {
872
+ e.relaySet.DeactivateRelayURL(relayURL)
873
+ e.relaySet.RemoveBootstrapRelayURL(relayURL)
874
+ }
875
}()
876
877
for {
sdk/expose_test.go
+4
-7
@@ -39,13 +39,10 @@ func TestExposureConfigSnapshotsDoNotShareMutableState(t *testing.T) {
39
t.Fatalf("Metadata.Tags[0] = %q, want original tag", got)
40
}
41
42
- if err := exposure.updateCfg(func(cfg *ExposeConfig) error {
43
- cfg.MaxActiveRelays = 2
44
- cfg.Metadata = types.LeaseMetadata{Tags: []string{"updated"}}
45
- return nil
46
- }); err != nil {
47
- t.Fatalf("Update() error = %v", err)
48
- }
42
+ exposure.cfgMu.Lock()
43
+ exposure.cfg.MaxActiveRelays = 2
44
+ exposure.cfg.Metadata = types.LeaseMetadata{Tags: []string{"updated"}}
45
+ exposure.cfgMu.Unlock()
46
47
metadata := exposure.metadata()
48
metadata.Tags[0] = "mutated"
sdk/listener.go
+2
@@ -75,6 +75,8 @@ type listener struct {
75
httpTransport *http.Transport
76
tlsConfig *tls.Config
77
78
+ releaseVersion string
79
+
80
leaseMu sync.RWMutex
81
lease *listenerSnapshot
82
}
types/agent.go
+1
@@ -23,6 +23,7 @@ type AgentTunnelStatus struct {
23
type AgentRelayStatus struct {
24
RelayURL string `json:"relay_url"`
25
PublicURL string `json:"public_url,omitempty"`
26
+ Version string `json:"version,omitempty"`
27
Explicit bool `json:"explicit,omitempty"`
28
Connecting bool `json:"connecting"`
29
Bootstrap bool `json:"bootstrap"`