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"`