main
go 2,403 lines 66.2 KB
Raw
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "net/url"
7 "os/exec"
8 "runtime"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/charmbracelet/bubbles/textinput"
15 tea "github.com/charmbracelet/bubbletea"
16 "github.com/charmbracelet/lipgloss"
17
18 "github.com/gosuda/portal-tunnel/v2/types"
19 "github.com/gosuda/portal-tunnel/v2/utils"
20 )
21
22 const (
23 agentDashboardPollInterval = 2 * time.Second
24 agentDashboardMinRelayRows = 5
25 agentDashboardSidebarGutter = 2
26 agentDashboardSidebarWidth = 32
27 agentDashboardTunnelInputMaxWidth = 80
28 )
29
30 type agentDashboardAction int
31
32 const (
33 agentDashboardActionSelectTunnel agentDashboardAction = iota + 1
34 agentDashboardActionSelectPane
35 agentDashboardActionAddTunnel
36 agentDashboardActionCancelAddTunnel
37 agentDashboardActionDeleteTunnel
38 agentDashboardActionConnectRelay
39 agentDashboardActionDisconnectRelay
40 agentDashboardActionAddHop
41 agentDashboardActionApplyHop
42 agentDashboardActionClearHop
43 agentDashboardActionApplySettings
44 agentDashboardActionFocusSettingsField
45 agentDashboardActionFocusAddTunnelField
46 agentDashboardActionOpenTunnelURL
47 )
48
49 type agentDashboardPane int
50
51 const (
52 agentDashboardPaneTunnels agentDashboardPane = iota
53 agentDashboardPaneSettings
54 agentDashboardPaneRelays
55 agentDashboardPaneMultiHop
56 agentDashboardPaneCount
57 )
58
59 const (
60 agentDashboardAddFieldName = iota
61 agentDashboardAddFieldTarget
62 agentDashboardAddFieldHTTPRoutes
63 agentDashboardAddFieldX402PayTo
64 agentDashboardAddFieldX402Testnet
65 agentDashboardAddFieldRelays
66 agentDashboardAddFieldDiscovery
67 agentDashboardAddFieldMaxRelays
68 agentDashboardAddFieldCount
69 )
70
71 const (
72 agentDashboardSettingsFieldMaxActiveRelays = iota
73 agentDashboardSettingsFieldDescription
74 agentDashboardSettingsFieldTags
75 agentDashboardSettingsFieldOwner
76 agentDashboardSettingsFieldThumbnail
77 agentDashboardSettingsFieldHide
78 agentDashboardSettingsFieldCount
79 )
80
81 type agentDashboardModel struct {
82 configPath string
83 stateDir string
84
85 status types.AgentStatusResponse
86 err error
87
88 width int
89 height int
90
91 sidebarScrollX int
92 sidebarDragX int
93 sidebarDragging bool
94
95 selectedTunnelID string
96 selectedRelayURL string
97 activePane agentDashboardPane
98 relayAttempts map[string]bool
99
100 routeDraft []string
101 draftTunnelID string
102
103 addingTunnel bool
104 addFocus int
105 addName textinput.Model
106 addTarget textinput.Model
107 addHTTPRoutes textinput.Model
108 addX402PayTo textinput.Model
109 addX402Testnet textinput.Model
110 addRelays textinput.Model
111 addDiscovery textinput.Model
112 addMaxRelays textinput.Model
113
114 settingsEditTunnelID string
115 settingsFocus int
116 settingsMaxRelays textinput.Model
117 metadataDescription textinput.Model
118 metadataTags textinput.Model
119 metadataOwner textinput.Model
120 metadataThumbnail textinput.Model
121 metadataHide textinput.Model
122 }
123
124 type agentDashboardStatusMsg struct {
125 status types.AgentStatusResponse
126 err error
127 }
128
129 type agentDashboardActionMsg struct {
130 err error
131 }
132
133 type agentDashboardTickMsg struct{}
134
135 type agentDashboardRegion struct {
136 x0 int
137 x1 int
138 y int
139 action agentDashboardAction
140 tunnel string
141 relay string
142 field int
143 pane agentDashboardPane
144 }
145
146 type agentDashboardButton struct {
147 label string
148 action agentDashboardAction
149 disabled bool
150 }
151
152 type agentDashboardView struct {
153 lines []string
154 regions []agentDashboardRegion
155 }
156
157 var (
158 agentDashboardSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("45"))
159 agentDashboardMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
160 agentDashboardRuleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
161 agentDashboardHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("250"))
162 agentDashboardSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
163 agentDashboardBrandStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("93"))
164 agentDashboardLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
165 agentDashboardButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("238"))
166 agentDashboardDisabledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
167 agentDashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
168 agentDashboardOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
169 agentDashboardPendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
170 agentDashboardInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
171 )
172
173 func RunDashboard(configPath, stateDir string) error {
174 model := agentDashboardModel{
175 configPath: configPath,
176 stateDir: stateDir,
177 addName: newAgentDashboardInlineInput("myapp"),
178 addTarget: newAgentDashboardInlineInput("3000"),
179 addHTTPRoutes: newAgentDashboardInlineInput("/paid=3001 GET:0.01; /=5173"),
180 addX402PayTo: newAgentDashboardInlineInput("0x..."),
181 addX402Testnet: newAgentDashboardInlineInput("false"),
182 addRelays: newAgentDashboardInlineInput("https://portal.example.com"),
183 addDiscovery: newAgentDashboardInlineInput("true"),
184 addMaxRelays: newAgentDashboardInlineInput("3"),
185 settingsMaxRelays: newAgentDashboardInlineInput("3"),
186 metadataDescription: newAgentDashboardInlineInput("description"),
187 metadataTags: newAgentDashboardInlineInput("api,staging"),
188 metadataOwner: newAgentDashboardInlineInput("owner"),
189 metadataThumbnail: newAgentDashboardInlineInput("https://..."),
190 metadataHide: newAgentDashboardInlineInput("true or false"),
191 }
192 model.resetAddTunnelForm()
193 model.resizeInputs(0)
194
195 _, err := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
196 return err
197 }
198
199 func newAgentDashboardTextInput() textinput.Model {
200 input := textinput.New()
201 input.CharLimit = 512
202 input.PromptStyle = agentDashboardSectionStyle
203 input.TextStyle = agentDashboardInputStyle
204 input.PlaceholderStyle = agentDashboardMutedStyle
205 return input
206 }
207
208 func newAgentDashboardInlineInput(placeholder string) textinput.Model {
209 input := newAgentDashboardTextInput()
210 input.Placeholder = placeholder
211 return input
212 }
213
214 func (m agentDashboardModel) Init() tea.Cmd {
215 return tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick(), textinput.Blink)
216 }
217
218 func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
219 switch msg := msg.(type) {
220 case tea.WindowSizeMsg:
221 m.width = msg.Width
222 m.height = msg.Height
223 m.resizeInputs(msg.Width)
224 m.clampSidebarScroll()
225 return m, nil
226 case agentDashboardTickMsg:
227 return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
228 case agentDashboardStatusMsg:
229 m.err = msg.err
230 if msg.err == nil {
231 m.status = msg.status
232 if strings.TrimSpace(msg.status.ConfigPath) != "" {
233 m.configPath = msg.status.ConfigPath
234 }
235 m.clampSelection()
236 m.ensureSelectedSettingsDraft()
237 m.syncRelayAttempts()
238 m.clampSidebarScroll()
239 }
240 return m, nil
241 case agentDashboardActionMsg:
242 m.err = msg.err
243 return m, agentDashboardFetchStatus(m.stateDir)
244 case tea.KeyMsg:
245 return m.updateKeys(msg)
246 case tea.MouseMsg:
247 return m.updateMouse(msg)
248 default:
249 return m, nil
250 }
251 }
252
253 func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
254 switch msg.String() {
255 case "ctrl+c":
256 return m, tea.Quit
257 case "esc":
258 m.err = nil
259 if m.activePane == agentDashboardPaneTunnels {
260 if m.addingTunnel {
261 m.cancelTunnelInput()
262 }
263 } else {
264 m.setActivePane(agentDashboardPaneTunnels)
265 }
266 return m, nil
267 case "left":
268 m.setActivePane(m.activePane - 1)
269 return m, nil
270 case "right":
271 m.setActivePane(m.activePane + 1)
272 return m, nil
273 }
274
275 switch m.activePane {
276 case agentDashboardPaneTunnels:
277 return m.updateTunnelKeys(msg)
278 case agentDashboardPaneRelays:
279 return m.updateRelayKeys(msg)
280 case agentDashboardPaneSettings:
281 return m.updateSettingsKeys(msg)
282 case agentDashboardPaneMultiHop:
283 return m.updateMultiHopKeys(msg)
284 default:
285 m.setActivePane(agentDashboardPaneTunnels)
286 return m, nil
287 }
288 }
289
290 func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
291 if m.addingTunnel {
292 switch msg.String() {
293 case "tab", "down":
294 m.focusAddTunnelField(m.addFocus + 1)
295 return m, nil
296 case "shift+tab", "up":
297 m.focusAddTunnelField(m.addFocus - 1)
298 return m, nil
299 case "enter":
300 return m.addTunnelFromInput()
301 }
302
303 input := m.focusedAddTunnelInput()
304 if input == nil {
305 return m, nil
306 }
307 var cmd tea.Cmd
308 *input, cmd = input.Update(msg)
309 return m, cmd
310 }
311
312 switch msg.String() {
313 case "up":
314 m.selectTunnelOffset(-1)
315 return m, nil
316 case "down":
317 m.selectTunnelOffset(1)
318 return m, nil
319 case "delete":
320 return m.deleteTunnel("")
321 }
322 return m, nil
323 }
324
325 func (m agentDashboardModel) updateRelayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
326 switch msg.String() {
327 case "up":
328 m.selectRelayOffset(-1)
329 case "down":
330 m.selectRelayOffset(1)
331 case "enter", "c":
332 return m.connectSelectedRelay()
333 case "d", "delete":
334 return m.disconnectSelectedRelay()
335 case "o":
336 return m.openRelayTunnelURL("", "")
337 }
338 return m, nil
339 }
340
341 func (m agentDashboardModel) updateMultiHopKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
342 switch msg.String() {
343 case "up":
344 m.selectRelayOffset(-1)
345 case "down":
346 m.selectRelayOffset(1)
347 case "enter", "a":
348 return m.addSelectedHop()
349 case "p":
350 return m.applyRoute()
351 case "c", "delete":
352 return m.clearRoute()
353 }
354 return m, nil
355 }
356
357 func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
358 event := tea.MouseEvent(msg)
359 switch event.Button {
360 case tea.MouseButtonWheelUp:
361 return m.scrollActivePane(-1)
362 case tea.MouseButtonWheelDown:
363 return m.scrollActivePane(1)
364 }
365 if m.sidebarDragging {
366 switch event.Action {
367 case tea.MouseActionMotion:
368 m.sidebarScrollX += m.sidebarDragX - event.X
369 m.sidebarDragX = event.X
370 m.clampSidebarScroll()
371 return m, nil
372 case tea.MouseActionRelease:
373 m.sidebarDragging = false
374 return m, nil
375 }
376 }
377 if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
378 return m, nil
379 }
380 if m.mouseInSidebar(event) {
381 m.sidebarDragging = true
382 m.sidebarDragX = event.X
383 return m, nil
384 }
385 for _, region := range m.layout().regions {
386 if event.Y == region.y && event.X >= region.x0 && event.X < region.x1 {
387 if region.action == agentDashboardActionSelectPane {
388 m.setActivePane(region.pane)
389 return m, nil
390 }
391 if region.action == agentDashboardActionFocusSettingsField {
392 m.setActivePane(agentDashboardPaneSettings)
393 m.focusSettingsField(region.field)
394 return m, nil
395 }
396 if region.action == agentDashboardActionFocusAddTunnelField {
397 m.setActivePane(agentDashboardPaneTunnels)
398 if !m.addingTunnel {
399 m.addingTunnel = true
400 m.resetAddTunnelForm()
401 }
402 m.focusAddTunnelField(region.field)
403 return m, nil
404 }
405 return m.runAction(region.action, region.tunnel, region.relay)
406 }
407 }
408 return m, nil
409 }
410
411 func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, relayURL string) (tea.Model, tea.Cmd) {
412 switch action {
413 case agentDashboardActionSelectTunnel:
414 if tunnelID != "" {
415 m.selectTunnel(tunnelID)
416 }
417 case agentDashboardActionAddTunnel:
418 return m.startOrAddTunnel()
419 case agentDashboardActionCancelAddTunnel:
420 m.cancelTunnelInput()
421 case agentDashboardActionDeleteTunnel:
422 return m.deleteTunnel(tunnelID)
423 case agentDashboardActionConnectRelay:
424 return m.connectSelectedRelay()
425 case agentDashboardActionDisconnectRelay:
426 return m.disconnectSelectedRelay()
427 case agentDashboardActionAddHop:
428 return m.addSelectedHop()
429 case agentDashboardActionApplyHop:
430 return m.applyRoute()
431 case agentDashboardActionClearHop:
432 return m.clearRoute()
433 case agentDashboardActionApplySettings:
434 return m.applySettingsEdit()
435 case agentDashboardActionOpenTunnelURL:
436 return m.openRelayTunnelURL(tunnelID, relayURL)
437 }
438 return m, nil
439 }
440
441 func (m agentDashboardModel) View() string {
442 layout := m.layout()
443 lines := layout.lines
444 if m.height > 0 {
445 if len(lines) > m.height {
446 lines = lines[:m.height]
447 }
448 for len(lines) < m.height {
449 lines = append(lines, "")
450 }
451 }
452 return strings.Join(lines, "\n")
453 }
454
455 func (m agentDashboardModel) selectedTunnelIndex() int {
456 if len(m.status.Tunnels) == 0 {
457 return -1
458 }
459 for i, tunnel := range m.status.Tunnels {
460 if tunnel.ID == m.selectedTunnelID {
461 return i
462 }
463 }
464 return 0
465 }
466
467 func (m *agentDashboardModel) selectTunnel(id string) {
468 for i, tunnel := range m.status.Tunnels {
469 if tunnel.ID == id {
470 m.selectTunnelIndex(i)
471 return
472 }
473 }
474 }
475
476 func (m *agentDashboardModel) selectTunnelIndex(index int) {
477 if index < 0 || index >= len(m.status.Tunnels) {
478 return
479 }
480 tunnelID := m.status.Tunnels[index].ID
481 if m.selectedTunnelID != tunnelID {
482 m.routeDraft = nil
483 m.draftTunnelID = ""
484 }
485 m.selectedTunnelID = tunnelID
486 m.selectedRelayURL = ""
487 if len(m.status.Tunnels[index].Relays) > 0 {
488 m.selectedRelayURL = m.status.Tunnels[index].Relays[0].RelayURL
489 }
490 m.loadSettingsDraft(m.status.Tunnels[index])
491 }
492
493 func (m *agentDashboardModel) selectTunnelOffset(delta int) {
494 index := m.selectedTunnelIndex()
495 next := index + delta
496 if next >= 0 && next < len(m.status.Tunnels) {
497 m.selectTunnelIndex(next)
498 }
499 }
500
501 func (m *agentDashboardModel) selectRelay(relayURL string) {
502 m.selectedRelayURL = relayURL
503 }
504
505 func (m *agentDashboardModel) selectRelayOffset(delta int) {
506 tunnel, ok := m.selectedTunnelStatus()
507 index := m.selectedRelayIndex(tunnel)
508 next := index + delta
509 if !ok || next < 0 || next >= len(tunnel.Relays) {
510 return
511 }
512 m.selectRelay(tunnel.Relays[next].RelayURL)
513 }
514
515 func (m *agentDashboardModel) setActivePane(pane agentDashboardPane) {
516 if pane < 0 {
517 pane = agentDashboardPaneCount - 1
518 }
519 if pane >= agentDashboardPaneCount {
520 pane = 0
521 }
522 m.activePane = pane
523 m.blurAddTunnelInputs()
524 m.blurSettingsInputs()
525 switch pane {
526 case agentDashboardPaneTunnels:
527 if m.addingTunnel {
528 m.focusAddTunnelField(m.addFocus)
529 }
530 case agentDashboardPaneSettings:
531 m.ensureSelectedSettingsDraft()
532 if input := m.focusedSettingsInput(); input != nil {
533 _ = input.Focus()
534 }
535 }
536 }
537
538 func (m agentDashboardModel) scrollActivePane(delta int) (tea.Model, tea.Cmd) {
539 m.selectRelayOffset(delta)
540 return m, nil
541 }
542
543 func (m agentDashboardModel) mouseInSidebar(event tea.MouseEvent) bool {
544 mainWidth, gutter, _ := agentDashboardColumnWidths(m.width)
545 return event.X >= mainWidth+gutter
546 }
547
548 func (m *agentDashboardModel) clampSidebarScroll() {
549 _, _, sidebarWidth := agentDashboardColumnWidths(m.width)
550 m.sidebarScrollX = max(0, min(m.sidebarScrollX, max(0, m.sidebarContentWidth()-sidebarWidth)))
551 }
552
553 func (m agentDashboardModel) sidebarContentWidth() int {
554 configPath := strings.TrimSpace(m.status.ConfigPath)
555 if configPath == "" {
556 configPath = strings.TrimSpace(m.configPath)
557 }
558 contentWidth := lipgloss.Width("PORTAL")
559 contentWidth = max(contentWidth, agentDashboardMetaWidth(configPath))
560 contentWidth = max(contentWidth, agentDashboardMetaWidth(strings.TrimSpace(m.status.ControlAddr)))
561 contentWidth = max(contentWidth, agentDashboardMetaWidth(strconv.Itoa(len(m.status.Tunnels))))
562 if wallet := strings.TrimSpace(m.status.WalletAddress); wallet != "" {
563 contentWidth = max(contentWidth, agentDashboardMetaWidth(wallet))
564 }
565 return contentWidth
566 }
567
568 func (m *agentDashboardModel) clampSelection() {
569 if len(m.status.Tunnels) == 0 {
570 m.selectedTunnelID = ""
571 m.selectedRelayURL = ""
572 m.routeDraft = nil
573 m.draftTunnelID = ""
574 m.clearSettingsDraft()
575 return
576 }
577
578 tunnelIndex := m.selectedTunnelIndex()
579 tunnelID := m.status.Tunnels[tunnelIndex].ID
580 if m.selectedTunnelID != tunnelID {
581 m.routeDraft = nil
582 m.draftTunnelID = ""
583 }
584 m.selectedTunnelID = tunnelID
585 m.ensureSettingsDraft(m.status.Tunnels[tunnelIndex])
586
587 relays := m.status.Tunnels[tunnelIndex].Relays
588 if len(relays) == 0 {
589 m.selectedRelayURL = ""
590 return
591 }
592 if m.selectedRelayURL != "" {
593 for _, relay := range relays {
594 if relay.RelayURL == m.selectedRelayURL {
595 return
596 }
597 }
598 }
599 m.selectedRelayURL = relays[0].RelayURL
600 }
601
602 func (m agentDashboardModel) selectedTunnelStatus() (types.AgentTunnelStatus, bool) {
603 index := m.selectedTunnelIndex()
604 if index < 0 {
605 return types.AgentTunnelStatus{}, false
606 }
607 return m.status.Tunnels[index], true
608 }
609
610 func (m agentDashboardModel) selectedRelayIndex(tunnel types.AgentTunnelStatus) int {
611 if len(tunnel.Relays) == 0 {
612 return -1
613 }
614 for i, relay := range tunnel.Relays {
615 if relay.RelayURL == m.selectedRelayURL {
616 return i
617 }
618 }
619 return 0
620 }
621
622 func (m agentDashboardModel) selectedRelayStatus() (types.AgentRelayStatus, bool) {
623 tunnel, ok := m.selectedTunnelStatus()
624 index := m.selectedRelayIndex(tunnel)
625 if !ok || index < 0 {
626 return types.AgentRelayStatus{}, false
627 }
628 return tunnel.Relays[index], true
629 }
630
631 func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, types.AgentRelayStatus, bool) {
632 tunnel, ok := m.selectedTunnelStatus()
633 if !ok {
634 return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
635 }
636 relay, ok := m.selectedRelayStatus()
637 if !ok {
638 return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
639 }
640 return tunnel, relay, true
641 }
642
643 func (m *agentDashboardModel) trackRelayAttempt(tunnelID, relayURL string) {
644 key := agentDashboardRelayKey(tunnelID, relayURL)
645 if key == "" {
646 return
647 }
648 if m.relayAttempts == nil {
649 m.relayAttempts = make(map[string]bool)
650 }
651 m.relayAttempts[key] = false
652 }
653
654 func (m *agentDashboardModel) clearRelayAttempt(tunnelID, relayURL string) {
655 key := agentDashboardRelayKey(tunnelID, relayURL)
656 delete(m.relayAttempts, key)
657 if len(m.relayAttempts) == 0 {
658 m.relayAttempts = nil
659 }
660 }
661
662 func (m *agentDashboardModel) syncRelayAttempts() {
663 if len(m.relayAttempts) == 0 {
664 return
665 }
666 seen := make(map[string]struct{})
667 for _, tunnel := range m.status.Tunnels {
668 for _, relay := range tunnel.Relays {
669 key := agentDashboardRelayKey(tunnel.ID, relay.RelayURL)
670 if key == "" {
671 continue
672 }
673 seen[key] = struct{}{}
674 if _, ok := m.relayAttempts[key]; !ok {
675 continue
676 }
677 if relayDashboardConnected(tunnel, relay) {
678 delete(m.relayAttempts, key)
679 continue
680 }
681 if relay.Connecting {
682 m.relayAttempts[key] = false
683 } else {
684 m.relayAttempts[key] = true
685 }
686 }
687 }
688 for key := range m.relayAttempts {
689 if _, ok := seen[key]; !ok {
690 delete(m.relayAttempts, key)
691 }
692 }
693 if len(m.relayAttempts) == 0 {
694 m.relayAttempts = nil
695 }
696 }
697
698 func (m agentDashboardModel) relayDashboardFailed(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
699 if relayDashboardConnected(tunnel, relay) || relay.Connecting {
700 return false
701 }
702 failed, ok := m.relayAttempts[agentDashboardRelayKey(tunnel.ID, relay.RelayURL)]
703 return ok && failed
704 }
705
706 func (m agentDashboardModel) relayDashboardConnecting(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
707 if relayDashboardConnected(tunnel, relay) || relay.Connecting {
708 return relay.Connecting
709 }
710 failed, ok := m.relayAttempts[agentDashboardRelayKey(tunnel.ID, relay.RelayURL)]
711 return ok && !failed
712 }
713
714 func agentDashboardRelayKey(tunnelID, relayURL string) string {
715 tunnelID = strings.TrimSpace(tunnelID)
716 relayURL = strings.TrimSpace(relayURL)
717 if tunnelID == "" || relayURL == "" {
718 return ""
719 }
720 return tunnelID + "\x00" + relayURL
721 }
722
723 func (m *agentDashboardModel) focusAddTunnelField(field int) {
724 fieldCount := agentDashboardAddFieldCount
725 if field < 0 {
726 field = fieldCount - 1
727 }
728 if field >= fieldCount {
729 field = 0
730 }
731 m.blurSettingsInputs()
732 m.addFocus = field
733 m.blurAddTunnelInputs()
734 if input := m.focusedAddTunnelInput(); input != nil {
735 _ = input.Focus()
736 }
737 }
738
739 func (m *agentDashboardModel) focusedAddTunnelInput() *textinput.Model {
740 switch m.addFocus {
741 case agentDashboardAddFieldName:
742 return &m.addName
743 case agentDashboardAddFieldTarget:
744 return &m.addTarget
745 case agentDashboardAddFieldHTTPRoutes:
746 return &m.addHTTPRoutes
747 case agentDashboardAddFieldX402PayTo:
748 return &m.addX402PayTo
749 case agentDashboardAddFieldX402Testnet:
750 return &m.addX402Testnet
751 case agentDashboardAddFieldRelays:
752 return &m.addRelays
753 case agentDashboardAddFieldDiscovery:
754 return &m.addDiscovery
755 case agentDashboardAddFieldMaxRelays:
756 return &m.addMaxRelays
757 default:
758 return nil
759 }
760 }
761
762 func (m *agentDashboardModel) blurAddTunnelInputs() {
763 for _, input := range []*textinput.Model{
764 &m.addName,
765 &m.addTarget,
766 &m.addHTTPRoutes,
767 &m.addX402PayTo,
768 &m.addX402Testnet,
769 &m.addRelays,
770 &m.addDiscovery,
771 &m.addMaxRelays,
772 } {
773 input.Blur()
774 }
775 }
776
777 func (m *agentDashboardModel) resetAddTunnelForm() {
778 for _, input := range []*textinput.Model{
779 &m.addName,
780 &m.addTarget,
781 &m.addHTTPRoutes,
782 &m.addX402PayTo,
783 &m.addX402Testnet,
784 &m.addRelays,
785 } {
786 input.Reset()
787 }
788 m.addX402Testnet.SetValue("false")
789 m.addDiscovery.SetValue("true")
790 m.addMaxRelays.SetValue("3")
791 m.addX402Testnet.CursorEnd()
792 m.addDiscovery.CursorEnd()
793 m.addMaxRelays.CursorEnd()
794 m.addFocus = agentDashboardAddFieldName
795 m.blurAddTunnelInputs()
796 }
797
798 func (m agentDashboardModel) updateSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
799 switch msg.String() {
800 case "tab", "down":
801 m.focusSettingsField(m.settingsFocus + 1)
802 return m, nil
803 case "shift+tab", "up":
804 m.focusSettingsField(m.settingsFocus - 1)
805 return m, nil
806 case "enter":
807 return m.applySettingsEdit()
808 }
809
810 input := m.focusedSettingsInput()
811 if input == nil {
812 return m, nil
813 }
814 var cmd tea.Cmd
815 *input, cmd = input.Update(msg)
816 return m, cmd
817 }
818
819 func (m *agentDashboardModel) focusSettingsField(field int) {
820 fieldCount := agentDashboardSettingsFieldCount
821 if field < 0 {
822 field = fieldCount - 1
823 }
824 if field >= fieldCount {
825 field = 0
826 }
827 m.blurAddTunnelInputs()
828 m.settingsFocus = field
829 for _, input := range []*textinput.Model{
830 &m.settingsMaxRelays,
831 &m.metadataDescription,
832 &m.metadataTags,
833 &m.metadataOwner,
834 &m.metadataThumbnail,
835 &m.metadataHide,
836 } {
837 input.Blur()
838 }
839 if input := m.focusedSettingsInput(); input != nil {
840 _ = input.Focus()
841 }
842 }
843
844 func (m *agentDashboardModel) focusedSettingsInput() *textinput.Model {
845 switch m.settingsFocus {
846 case agentDashboardSettingsFieldMaxActiveRelays:
847 return &m.settingsMaxRelays
848 case agentDashboardSettingsFieldDescription:
849 return &m.metadataDescription
850 case agentDashboardSettingsFieldTags:
851 return &m.metadataTags
852 case agentDashboardSettingsFieldOwner:
853 return &m.metadataOwner
854 case agentDashboardSettingsFieldThumbnail:
855 return &m.metadataThumbnail
856 case agentDashboardSettingsFieldHide:
857 return &m.metadataHide
858 default:
859 return nil
860 }
861 }
862
863 func (m *agentDashboardModel) blurSettingsInputs() {
864 for _, input := range []*textinput.Model{
865 &m.settingsMaxRelays,
866 &m.metadataDescription,
867 &m.metadataTags,
868 &m.metadataOwner,
869 &m.metadataThumbnail,
870 &m.metadataHide,
871 } {
872 input.Blur()
873 }
874 }
875
876 func (m *agentDashboardModel) clearSettingsDraft() {
877 m.settingsEditTunnelID = ""
878 for _, input := range []*textinput.Model{
879 &m.settingsMaxRelays,
880 &m.metadataDescription,
881 &m.metadataTags,
882 &m.metadataOwner,
883 &m.metadataThumbnail,
884 &m.metadataHide,
885 } {
886 input.Reset()
887 }
888 m.blurSettingsInputs()
889 if m.activePane == agentDashboardPaneTunnels && m.addingTunnel {
890 m.focusAddTunnelField(m.addFocus)
891 }
892 }
893
894 func (m *agentDashboardModel) resizeInputs(width int) {
895 if width <= 0 {
896 width = 88
897 }
898 contentWidth, _, _ := agentDashboardColumnWidths(width)
899 settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, contentWidth-13))
900 for _, input := range []*textinput.Model{
901 &m.addName,
902 &m.addTarget,
903 &m.addHTTPRoutes,
904 &m.addX402PayTo,
905 &m.addX402Testnet,
906 &m.addRelays,
907 &m.addDiscovery,
908 &m.addMaxRelays,
909 &m.settingsMaxRelays,
910 &m.metadataDescription,
911 &m.metadataTags,
912 &m.metadataOwner,
913 &m.metadataThumbnail,
914 &m.metadataHide,
915 } {
916 input.Width = settingsWidth
917 }
918 }
919
920 func (m *agentDashboardModel) ensureSelectedSettingsDraft() {
921 tunnel, ok := m.selectedTunnelStatus()
922 if !ok {
923 return
924 }
925 m.ensureSettingsDraft(tunnel)
926 }
927
928 func (m *agentDashboardModel) ensureSettingsDraft(tunnel types.AgentTunnelStatus) {
929 if m.settingsEditTunnelID == tunnel.ID {
930 return
931 }
932 m.loadSettingsDraft(tunnel)
933 }
934
935 func (m *agentDashboardModel) loadSettingsDraft(tunnel types.AgentTunnelStatus) {
936 metadata := tunnel.Metadata
937 m.settingsEditTunnelID = tunnel.ID
938 m.settingsMaxRelays.SetValue(strconv.Itoa(tunnel.MaxActiveRelays))
939 m.metadataDescription.SetValue(strings.TrimSpace(metadata.Description))
940 m.metadataTags.SetValue(strings.Join(metadata.Tags, ","))
941 m.metadataOwner.SetValue(strings.TrimSpace(metadata.Owner))
942 m.metadataThumbnail.SetValue(strings.TrimSpace(metadata.Thumbnail))
943 m.metadataHide.SetValue(strconv.FormatBool(metadata.Hide))
944 m.settingsMaxRelays.CursorEnd()
945 m.metadataDescription.CursorEnd()
946 m.metadataTags.CursorEnd()
947 m.metadataOwner.CursorEnd()
948 m.metadataThumbnail.CursorEnd()
949 m.metadataHide.CursorEnd()
950 }
951
952 func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
953 req, err := m.addTunnelRequest()
954 if err != nil {
955 m.err = err
956 return m, nil
957 }
958
959 m.err = nil
960 m.addingTunnel = false
961 m.resetAddTunnelForm()
962 return m, agentDashboardRun(func(ctx context.Context) error {
963 return AddTunnel(ctx, m.stateDir, req)
964 })
965 }
966
967 func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error) {
968 name := strings.TrimSpace(m.addName.Value())
969 if agentTunnelID(name) == "" {
970 return types.AgentTunnelRequest{}, fmt.Errorf("tunnel name is required")
971 }
972
973 targetInput := strings.TrimSpace(m.addTarget.Value())
974 routesInput := strings.TrimSpace(m.addHTTPRoutes.Value())
975 if targetInput != "" && routesInput != "" {
976 return types.AgentTunnelRequest{}, fmt.Errorf("target cannot be combined with routes")
977 }
978 if targetInput == "" && routesInput == "" {
979 return types.AgentTunnelRequest{}, fmt.Errorf("target or routes is required")
980 }
981
982 var target string
983 if targetInput != "" {
984 var err error
985 target, err = utils.NormalizeLoopbackTarget(targetInput)
986 if err != nil || target == "" {
987 return types.AgentTunnelRequest{}, fmt.Errorf("invalid target %q", targetInput)
988 }
989 }
990
991 routes, err := agentDashboardParseAddHTTPRoutes(routesInput)
992 if err != nil {
993 return types.AgentTunnelRequest{}, err
994 }
995 payTo := strings.TrimSpace(m.addX402PayTo.Value())
996 hasPaidRoute := false
997 for _, route := range routes {
998 if strings.TrimSpace(route.Amount) != "" {
999 hasPaidRoute = true
1000 break
1001 }
1002 }
1003 if hasPaidRoute && payTo == "" {
1004 return types.AgentTunnelRequest{}, fmt.Errorf("paid routes require X402 Pay To")
1005 }
1006 if len(routes) == 0 && payTo != "" {
1007 return types.AgentTunnelRequest{}, fmt.Errorf("X402 Pay To requires routes")
1008 }
1009 x402TestnetRaw := strings.TrimSpace(m.addX402Testnet.Value())
1010 if x402TestnetRaw == "" {
1011 x402TestnetRaw = "false"
1012 }
1013 x402Testnet, err := strconv.ParseBool(x402TestnetRaw)
1014 if err != nil {
1015 return types.AgentTunnelRequest{}, fmt.Errorf("X402 Testnet must be true or false")
1016 }
1017 if x402Testnet && !hasPaidRoute {
1018 return types.AgentTunnelRequest{}, fmt.Errorf("X402 Testnet requires paid routes")
1019 }
1020
1021 discoveryRaw := strings.TrimSpace(m.addDiscovery.Value())
1022 if discoveryRaw == "" {
1023 discoveryRaw = "true"
1024 }
1025 discovery, err := strconv.ParseBool(discoveryRaw)
1026 if err != nil {
1027 return types.AgentTunnelRequest{}, fmt.Errorf("discovery must be true or false")
1028 }
1029
1030 maxRelaysRaw := strings.TrimSpace(m.addMaxRelays.Value())
1031 if maxRelaysRaw == "" {
1032 maxRelaysRaw = "3"
1033 }
1034 maxRelays, err := strconv.Atoi(maxRelaysRaw)
1035 if err != nil || maxRelays <= 0 {
1036 return types.AgentTunnelRequest{}, fmt.Errorf("max relays must be a positive integer")
1037 }
1038
1039 return types.AgentTunnelRequest{
1040 Name: name,
1041 TargetAddr: target,
1042 HTTPRoutes: routes,
1043 RelayURLs: utils.SplitCSV(m.addRelays.Value()),
1044 Discovery: &discovery,
1045 MaxActiveRelays: maxRelays,
1046 X402PayTo: payTo,
1047 X402Testnet: x402Testnet,
1048 }, nil
1049 }
1050
1051 func agentDashboardParseAddHTTPRoutes(value string) ([]types.AgentHTTPRoute, error) {
1052 value = strings.TrimSpace(value)
1053 if value == "" {
1054 return nil, nil
1055 }
1056 var routes []types.AgentHTTPRoute
1057 seen := make(map[string]struct{})
1058
1059 for _, rawSegment := range strings.Split(value, ";") {
1060 segment := strings.TrimSpace(rawSegment)
1061 if segment == "" {
1062 continue
1063 }
1064 key, rest, ok := strings.Cut(segment, "=")
1065 if !ok {
1066 return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1067 }
1068 key = strings.TrimSpace(key)
1069 rest = strings.TrimSpace(rest)
1070 if strings.EqualFold(key, "payto") || strings.EqualFold(key, "x402_pay_to") {
1071 return nil, fmt.Errorf("use the X402 Pay To field instead of %q in routes", key)
1072 }
1073 if key == "" {
1074 return nil, fmt.Errorf("route path is required")
1075 }
1076 if !strings.HasPrefix(key, "/") {
1077 return nil, fmt.Errorf("route path %q must start with /", key)
1078 }
1079
1080 parts := strings.Fields(rest)
1081 if len(parts) == 0 || len(parts) > 2 {
1082 return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1083 }
1084 if parts[0] == "" {
1085 return nil, fmt.Errorf("route %q upstream is required", key)
1086 }
1087
1088 prefix := utils.NormalizeURLPath(key)
1089 if _, ok := seen[prefix]; ok {
1090 return nil, fmt.Errorf("duplicate route path %q", prefix)
1091 }
1092 seen[prefix] = struct{}{}
1093
1094 route := types.AgentHTTPRoute{
1095 Prefix: prefix,
1096 Upstream: parts[0],
1097 }
1098 if len(parts) == 2 {
1099 methods, amount, err := agentDashboardParseAddRoutePayment(parts[1])
1100 if err != nil {
1101 return nil, fmt.Errorf("route %q: %w", prefix, err)
1102 }
1103 route.Methods = methods
1104 route.Amount = amount
1105 }
1106 routes = append(routes, route)
1107 }
1108
1109 if len(routes) == 0 {
1110 return nil, fmt.Errorf("at least one http route is required")
1111 }
1112 return routes, nil
1113 }
1114
1115 func agentDashboardParseAddRoutePayment(value string) ([]string, string, error) {
1116 value = strings.TrimSpace(value)
1117 if value == "" {
1118 return nil, "", fmt.Errorf("payment amount is required")
1119 }
1120 methodPart, amount, hasMethods := strings.Cut(value, ":")
1121 if !hasMethods {
1122 amount = value
1123 methodPart = ""
1124 }
1125 amount = strings.TrimSpace(amount)
1126 if amount == "" {
1127 return nil, "", fmt.Errorf("payment amount is required")
1128 }
1129 if !hasMethods {
1130 return nil, amount, nil
1131 }
1132
1133 methods := []string(nil)
1134 for _, rawMethod := range strings.Split(methodPart, ",") {
1135 method := strings.ToUpper(strings.TrimSpace(rawMethod))
1136 if method == "" {
1137 return nil, "", fmt.Errorf("payment method is required")
1138 }
1139 if !slices.Contains(methods, method) {
1140 methods = append(methods, method)
1141 }
1142 }
1143 if len(methods) == 0 {
1144 return nil, "", fmt.Errorf("payment methods are required before ':'")
1145 }
1146 return methods, amount, nil
1147 }
1148
1149 func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
1150 if !m.addingTunnel {
1151 m.addingTunnel = true
1152 m.resetAddTunnelForm()
1153 m.setActivePane(agentDashboardPaneTunnels)
1154 m.focusAddTunnelField(agentDashboardAddFieldName)
1155 return m, nil
1156 }
1157 return m.addTunnelFromInput()
1158 }
1159
1160 func (m *agentDashboardModel) cancelTunnelInput() {
1161 m.addingTunnel = false
1162 m.resetAddTunnelForm()
1163 }
1164
1165 func (m agentDashboardModel) applySettingsEdit() (tea.Model, tea.Cmd) {
1166 tunnel, ok := m.selectedTunnelStatus()
1167 if !ok {
1168 return m, nil
1169 }
1170 if !m.settingsChanged(tunnel) {
1171 return m, nil
1172 }
1173 maxActiveRelays, err := strconv.Atoi(strings.TrimSpace(m.settingsMaxRelays.Value()))
1174 if err != nil || maxActiveRelays <= 0 {
1175 m.err = fmt.Errorf("max active relays must be a positive integer")
1176 return m, nil
1177 }
1178
1179 hideRaw := strings.TrimSpace(m.metadataHide.Value())
1180 if hideRaw == "" {
1181 hideRaw = "false"
1182 }
1183 hide, err := strconv.ParseBool(hideRaw)
1184 if err != nil {
1185 m.err = fmt.Errorf("metadata hidden must be true or false")
1186 return m, nil
1187 }
1188
1189 description := strings.TrimSpace(m.metadataDescription.Value())
1190 tags := utils.SplitCSV(m.metadataTags.Value())
1191 owner := strings.TrimSpace(m.metadataOwner.Value())
1192 thumbnail := strings.TrimSpace(m.metadataThumbnail.Value())
1193 metadata := types.AgentMetadataRequest{
1194 Description: &description,
1195 Tags: &tags,
1196 Owner: &owner,
1197 Thumbnail: &thumbnail,
1198 Hide: &hide,
1199 }
1200 req := types.AgentTunnelUpdateRequest{
1201 MaxActiveRelays: &maxActiveRelays,
1202 Metadata: &metadata,
1203 }
1204 m.err = nil
1205 return m, agentDashboardRun(func(ctx context.Context) error {
1206 return UpdateTunnel(ctx, m.stateDir, tunnel.ID, req)
1207 })
1208 }
1209
1210 func (m agentDashboardModel) deleteTunnel(tunnelID string) (tea.Model, tea.Cmd) {
1211 tunnelID = strings.TrimSpace(tunnelID)
1212 if tunnelID == "" {
1213 tunnel, ok := m.selectedTunnelStatus()
1214 if !ok {
1215 return m, nil
1216 }
1217 tunnelID = tunnel.ID
1218 }
1219 return m, agentDashboardRun(func(ctx context.Context) error {
1220 return DeleteTunnel(ctx, m.stateDir, tunnelID)
1221 })
1222 }
1223
1224 func (m agentDashboardModel) connectSelectedRelay() (tea.Model, tea.Cmd) {
1225 tunnel, relay, ok := m.selectedTunnelRelay()
1226 if !ok {
1227 return m, nil
1228 }
1229 if relay.Banned || relayDashboardActive(tunnel, relay) || m.relayDashboardConnecting(tunnel, relay) {
1230 return m, nil
1231 }
1232 m.trackRelayAttempt(tunnel.ID, relay.RelayURL)
1233 return m, agentDashboardRun(func(ctx context.Context) error {
1234 return ConnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
1235 })
1236 }
1237
1238 func (m agentDashboardModel) disconnectSelectedRelay() (tea.Model, tea.Cmd) {
1239 tunnel, relay, ok := m.selectedTunnelRelay()
1240 if !ok {
1241 return m, nil
1242 }
1243 if relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL) {
1244 return m, nil
1245 }
1246 m.clearRelayAttempt(tunnel.ID, relay.RelayURL)
1247 return m, agentDashboardRun(func(ctx context.Context) error {
1248 return DisconnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
1249 })
1250 }
1251
1252 func (m agentDashboardModel) openRelayTunnelURL(tunnelID, relayURL string) (tea.Model, tea.Cmd) {
1253 if tunnelID != "" {
1254 m.selectTunnel(tunnelID)
1255 }
1256 if relayURL != "" {
1257 m.selectRelay(relayURL)
1258 }
1259 _, relay, ok := m.selectedTunnelRelay()
1260 publicURL := relayDashboardPublicURL(relay.PublicURL)
1261 if !ok || publicURL == "" {
1262 return m, nil
1263 }
1264 return m, agentDashboardRun(func(context.Context) error {
1265 return openDashboardURL(publicURL)
1266 })
1267 }
1268
1269 func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
1270 tunnel, relay, ok := m.selectedTunnelRelay()
1271 if !ok {
1272 return m, nil
1273 }
1274 if !relay.SupportsOverlay {
1275 return m, nil
1276 }
1277 m.ensureRouteDraft(tunnel)
1278 if slices.Contains(m.routeDraft, relay.RelayURL) {
1279 return m, nil
1280 }
1281 m.routeDraft = append(m.routeDraft, relay.RelayURL)
1282 return m, nil
1283 }
1284
1285 func (m agentDashboardModel) applyRoute() (tea.Model, tea.Cmd) {
1286 tunnel, ok := m.selectedTunnelStatus()
1287 if !ok {
1288 return m, nil
1289 }
1290 route := m.displayedRoute(tunnel)
1291 if len(route) < 2 {
1292 return m, nil
1293 }
1294 m.routeDraft = nil
1295 m.draftTunnelID = ""
1296 return m, agentDashboardRun(func(ctx context.Context) error {
1297 return SetMultiHop(ctx, m.stateDir, tunnel.ID, route)
1298 })
1299 }
1300
1301 func (m agentDashboardModel) clearRoute() (tea.Model, tea.Cmd) {
1302 tunnel, ok := m.selectedTunnelStatus()
1303 if !ok {
1304 return m, nil
1305 }
1306 m.routeDraft = nil
1307 m.draftTunnelID = ""
1308 return m, agentDashboardRun(func(ctx context.Context) error {
1309 return SetMultiHop(ctx, m.stateDir, tunnel.ID, nil)
1310 })
1311 }
1312
1313 func (m *agentDashboardModel) ensureRouteDraft(tunnel types.AgentTunnelStatus) {
1314 if m.draftTunnelID == tunnel.ID {
1315 return
1316 }
1317 m.draftTunnelID = tunnel.ID
1318 m.routeDraft = append([]string(nil), tunnel.MultiHop...)
1319 }
1320
1321 func (m agentDashboardModel) displayedRoute(tunnel types.AgentTunnelStatus) []string {
1322 if m.draftTunnelID == tunnel.ID {
1323 return append([]string(nil), m.routeDraft...)
1324 }
1325 return append([]string(nil), tunnel.MultiHop...)
1326 }
1327
1328 func agentDashboardFetchStatus(stateDir string) tea.Cmd {
1329 return func() tea.Msg {
1330 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1331 defer cancel()
1332
1333 status, err := Status(ctx, stateDir)
1334 return agentDashboardStatusMsg{status: status, err: err}
1335 }
1336 }
1337
1338 func agentDashboardTick() tea.Cmd {
1339 return tea.Tick(agentDashboardPollInterval, func(t time.Time) tea.Msg {
1340 return agentDashboardTickMsg{}
1341 })
1342 }
1343
1344 func agentDashboardRun(run func(context.Context) error) tea.Cmd {
1345 return func() tea.Msg {
1346 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1347 defer cancel()
1348
1349 return agentDashboardActionMsg{err: run(ctx)}
1350 }
1351 }
1352
1353 func (m agentDashboardModel) layout() agentDashboardView {
1354 width := m.width
1355 if width <= 0 {
1356 width = 88
1357 }
1358 mainWidth, gutter, sidebarWidth := agentDashboardColumnWidths(width)
1359 main := m.renderMainLayout(mainWidth)
1360 sidebar := m.renderSidebar(sidebarWidth, m.height)
1361 return agentDashboardJoinHorizontal(main, sidebar, mainWidth, gutter, width, m.height)
1362 }
1363
1364 func (m agentDashboardModel) renderMainLayout(width int) agentDashboardView {
1365 bodyHeight := defaultDashboardBodyHeight(m.height)
1366
1367 var layout agentDashboardView
1368 if m.err != nil && m.status.ControlAddr == "" {
1369 layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Agent unavailable: %v", m.err))
1370 layout.addLine("")
1371 layout.addText(width, "Start managed service: portal agent run --config "+m.configPath)
1372 layout.addText(width, "No service manager: portal agent run --foreground --config "+m.configPath)
1373 return layout
1374 }
1375
1376 if m.err != nil {
1377 layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Error: %v", m.err))
1378 }
1379 layout.addLine("")
1380 if m.height > 0 {
1381 bodyHeight = max(1, m.height-len(layout.lines))
1382 }
1383
1384 tunnels := m.renderTunnelsSection(width, m.tunnelsSectionHeight(bodyHeight))
1385 layout.addView(tunnels)
1386 layout.addLine("")
1387 if m.height > 0 {
1388 bodyHeight = max(1, m.height-len(layout.lines))
1389 }
1390 body := m.renderTunnelPane(width, bodyHeight)
1391 layout.addView(body)
1392 return layout
1393 }
1394
1395 func (m agentDashboardModel) renderSidebar(width, height int) agentDashboardView {
1396 var pane agentDashboardView
1397 pane.addStyled(width, agentDashboardRuleStyle, strings.Repeat("/", width))
1398 pane.addStyled(width, agentDashboardBrandStyle, "PORTAL")
1399 pane.addStyled(width, agentDashboardMutedStyle, "Agent "+types.ReleaseVersion)
1400 pane.addStyled(width, agentDashboardRuleStyle, strings.Repeat("-", width))
1401
1402 configPath := strings.TrimSpace(m.status.ConfigPath)
1403 if configPath == "" {
1404 configPath = strings.TrimSpace(m.configPath)
1405 }
1406 pane.addSidebarTitle(width, "Runtime")
1407 pane.addMeta(width, m.sidebarScrollX, "Config", configPath)
1408 pane.addMeta(width, m.sidebarScrollX, "Control", strings.TrimSpace(m.status.ControlAddr))
1409 pane.addMeta(width, m.sidebarScrollX, "Tunnels", strconv.Itoa(len(m.status.Tunnels)))
1410 if wallet := strings.TrimSpace(m.status.WalletAddress); wallet != "" {
1411 pane.addMeta(width, m.sidebarScrollX, "Wallet", wallet)
1412 }
1413
1414 pane.clip(height)
1415 return pane
1416 }
1417
1418 func (m agentDashboardModel) tunnelsSectionHeight(bodyHeight int) int {
1419 if bodyHeight <= 0 {
1420 return bodyHeight
1421 }
1422 if _, ok := m.selectedTunnelStatus(); !ok {
1423 return bodyHeight
1424 }
1425 minRelaySectionHeight := agentDashboardMinRelayRows + 3
1426 if bodyHeight <= minRelaySectionHeight {
1427 return 1
1428 }
1429 return max(1, bodyHeight-minRelaySectionHeight-1)
1430 }
1431
1432 func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashboardView {
1433 var pane agentDashboardView
1434 pane.addSectionTitle(width, agentDashboardPaneTunnels, "Tunnels", m.activePane == agentDashboardPaneTunnels)
1435 addLabel := "Add Tunnel"
1436 addDisabled := false
1437 if m.addingTunnel {
1438 addLabel = "Create"
1439 addDisabled = strings.TrimSpace(m.addName.Value()) == ""
1440 }
1441 buttons := []agentDashboardButton{
1442 {label: addLabel, action: agentDashboardActionAddTunnel, disabled: addDisabled},
1443 }
1444 if m.addingTunnel {
1445 buttons = append(buttons, agentDashboardButton{label: "Cancel", action: agentDashboardActionCancelAddTunnel})
1446 }
1447 buttons = append(buttons,
1448 agentDashboardButton{label: "Delete", action: agentDashboardActionDeleteTunnel, disabled: len(m.status.Tunnels) == 0},
1449 )
1450 pane.addButtons(width, buttons...)
1451 if m.addingTunnel {
1452 m.renderAddTunnelForm(&pane, width)
1453 }
1454 tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
1455 pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
1456
1457 if len(m.status.Tunnels) == 0 {
1458 pane.addStyled(width, agentDashboardMutedStyle, "no tunnels")
1459 return pane
1460 }
1461
1462 selectedTunnelID := m.selectedTunnelID
1463 if selectedTunnelID == "" && len(m.status.Tunnels) > 0 {
1464 selectedTunnelID = m.status.Tunnels[0].ID
1465 }
1466 maxRows := len(m.status.Tunnels)
1467 if height > 0 {
1468 maxRows = max(1, height-len(pane.lines)-2)
1469 }
1470 selectedIndex := m.selectedTunnelIndex()
1471 start, end := agentDashboardRelayWindow(selectedIndex, len(m.status.Tunnels), maxRows)
1472 rowWidth := tunnelRowWidth
1473 for i := start; i < end; i++ {
1474 tunnel := m.status.Tunnels[i]
1475 pane.addTunnelRow(width, rowWidth, tunnel, tunnel.ID == selectedTunnelID)
1476 }
1477
1478 if tunnel, ok := m.selectedTunnelStatus(); ok && strings.TrimSpace(tunnel.LastError) != "" {
1479 pane.addStyled(width, agentDashboardErrorStyle, "Error: "+tunnel.LastError)
1480 }
1481 return pane
1482 }
1483
1484 func (m agentDashboardModel) renderAddTunnelForm(pane *agentDashboardView, width int) {
1485 rows := []struct {
1486 label string
1487 input textinput.Model
1488 field int
1489 }{
1490 {label: "Name", input: m.addName, field: agentDashboardAddFieldName},
1491 {label: "Target", input: m.addTarget, field: agentDashboardAddFieldTarget},
1492 {label: "Routes", input: m.addHTTPRoutes, field: agentDashboardAddFieldHTTPRoutes},
1493 {label: "X402 Pay To", input: m.addX402PayTo, field: agentDashboardAddFieldX402PayTo},
1494 {label: "X402 Testnet", input: m.addX402Testnet, field: agentDashboardAddFieldX402Testnet},
1495 {label: "Relays", input: m.addRelays, field: agentDashboardAddFieldRelays},
1496 {label: "Discovery", input: m.addDiscovery, field: agentDashboardAddFieldDiscovery},
1497 {label: "Max Relays", input: m.addMaxRelays, field: agentDashboardAddFieldMaxRelays},
1498 }
1499 for _, row := range rows {
1500 pane.addAddTunnelInputRow(width, row.label, row.input, row.field, m.addFocus == row.field)
1501 }
1502 }
1503
1504 func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardView {
1505 var pane agentDashboardView
1506 tunnel, ok := m.selectedTunnelStatus()
1507 if !ok {
1508 pane.addSectionTitle(width, agentDashboardPaneSettings, "Settings", m.activePane == agentDashboardPaneSettings)
1509 pane.addStyled(width, agentDashboardMutedStyle, "select a tunnel")
1510 pane.clip(height)
1511 return pane
1512 }
1513
1514 m.renderSettingsSection(&pane, width, height, tunnel)
1515 pane.addLine("")
1516 relayLimit := m.relayRowsForHeight(tunnel, max(1, height-len(pane.lines)))
1517 m.renderRelaysSection(&pane, width, relayLimit, tunnel)
1518 pane.addLine("")
1519 m.renderRouteSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
1520 pane.clip(height)
1521 return pane
1522 }
1523
1524 func (m agentDashboardModel) relayRowsForHeight(tunnel types.AgentTunnelStatus, height int) int {
1525 if len(tunnel.Relays) == 0 {
1526 return 0
1527 }
1528 routeRows := len(m.displayedRoute(tunnel))
1529 routeReserve := min(max(5, routeRows+4), 9)
1530 relayRows := height - routeReserve - 4
1531 if relayRows < agentDashboardMinRelayRows {
1532 relayRows = min(agentDashboardMinRelayRows, len(tunnel.Relays))
1533 }
1534 return min(relayRows, len(tunnel.Relays))
1535 }
1536
1537 func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width, maxRows int, tunnel types.AgentTunnelStatus) {
1538 relay, hasRelay := m.selectedRelayStatus()
1539 connectDisabled := !hasRelay || relay.Banned || relayDashboardActive(tunnel, relay) || m.relayDashboardConnecting(tunnel, relay)
1540 disconnectDisabled := !hasRelay || relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL)
1541
1542 pane.addSectionTitle(width, agentDashboardPaneRelays, "Relays", m.activePane == agentDashboardPaneRelays)
1543 pane.addButtons(width,
1544 agentDashboardButton{label: "Connect", action: agentDashboardActionConnectRelay, disabled: connectDisabled},
1545 agentDashboardButton{label: "Disconnect", action: agentDashboardActionDisconnectRelay, disabled: disconnectDisabled},
1546 )
1547 pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardRelayRow(width, "STATUS", "VERSION", "FEATURES", "TUNNEL URL")))
1548
1549 if len(tunnel.Relays) == 0 {
1550 pane.addStyled(width, agentDashboardMutedStyle, "no relays")
1551 return
1552 }
1553 selectedRelayURL := m.selectedRelayURL
1554 if selectedRelayURL == "" && len(tunnel.Relays) > 0 {
1555 selectedRelayURL = tunnel.Relays[0].RelayURL
1556 }
1557 selectedRelayIndex := m.selectedRelayIndex(tunnel)
1558 start, end := agentDashboardRelayWindow(selectedRelayIndex, len(tunnel.Relays), maxRows)
1559 rowWidth := width
1560 for i := start; i < end; i++ {
1561 relay := tunnel.Relays[i]
1562 line := agentDashboardRelayRow(rowWidth,
1563 m.relayDashboardMode(tunnel, relay),
1564 relayDashboardVersion(relay),
1565 relayDashboardFeatures(relay),
1566 relayDashboardURL(relay),
1567 )
1568 pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, tunnel, relay, m.relayDashboardFailed(tunnel, relay), m.relayDashboardConnecting(tunnel, relay)), agentDashboardActionOpenTunnelURL, tunnel.ID, relay.RelayURL)
1569 }
1570 }
1571
1572 func (m agentDashboardModel) renderSettingsSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
1573 if height <= 0 {
1574 return
1575 }
1576 startLine := len(pane.lines)
1577 pane.addSectionTitle(width, agentDashboardPaneSettings, "Settings", m.activePane == agentDashboardPaneSettings)
1578 applyLabel := "Apply"
1579 settingsChanged := m.settingsChanged(tunnel)
1580 if m.settingsEditTunnelID == tunnel.ID && !settingsChanged {
1581 applyLabel = "Applied"
1582 }
1583 pane.addButtons(width,
1584 agentDashboardButton{label: applyLabel, action: agentDashboardActionApplySettings, disabled: !settingsChanged},
1585 )
1586 if len(pane.lines)-startLine >= height {
1587 return
1588 }
1589
1590 m.renderSettingsInputRows(pane, width, height, startLine, tunnel)
1591 }
1592
1593 func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, width, height, startLine int, tunnel types.AgentTunnelStatus) {
1594 rows := []struct {
1595 label string
1596 input textinput.Model
1597 field int
1598 }{
1599 {label: "Max Relays", input: m.settingsMaxRelays, field: agentDashboardSettingsFieldMaxActiveRelays},
1600 {label: "Description", input: m.metadataDescription, field: agentDashboardSettingsFieldDescription},
1601 {label: "Tags", input: m.metadataTags, field: agentDashboardSettingsFieldTags},
1602 {label: "Owner", input: m.metadataOwner, field: agentDashboardSettingsFieldOwner},
1603 {label: "Thumbnail", input: m.metadataThumbnail, field: agentDashboardSettingsFieldThumbnail},
1604 {label: "Hidden", input: m.metadataHide, field: agentDashboardSettingsFieldHide},
1605 }
1606 for _, row := range rows {
1607 if len(pane.lines)-startLine >= height {
1608 return
1609 }
1610 pane.addSettingsInputRow(width, row.label, row.input, row.field, m.settingsFocus == row.field)
1611 }
1612
1613 if len(pane.lines)-startLine >= height {
1614 return
1615 }
1616 pane.addMeta(width, 0, "Discovery", strconv.FormatBool(tunnel.Discovery))
1617
1618 paidRouteCount := 0
1619 for _, route := range tunnel.HTTPRoutes {
1620 if strings.TrimSpace(route.Amount) != "" {
1621 paidRouteCount++
1622 }
1623 }
1624 payTo := strings.TrimSpace(tunnel.X402PayTo)
1625 if payTo == "" && len(tunnel.HTTPRoutes) == 0 {
1626 return
1627 }
1628 if len(pane.lines)-startLine >= height {
1629 return
1630 }
1631 pane.addLine("")
1632 if len(pane.lines)-startLine >= height {
1633 return
1634 }
1635 pane.addStyled(width, agentDashboardLabelStyle, "Payments")
1636 if payTo != "" {
1637 if len(pane.lines)-startLine >= height {
1638 return
1639 }
1640 pane.addMeta(width, 0, "Pay To", payTo)
1641 }
1642 if payTo != "" || paidRouteCount > 0 {
1643 if len(pane.lines)-startLine >= height {
1644 return
1645 }
1646 pane.addMeta(width, 0, "Network", agentDashboardX402Network(tunnel.X402Testnet))
1647 }
1648 if len(tunnel.HTTPRoutes) == 0 {
1649 if len(pane.lines)-startLine >= height {
1650 return
1651 }
1652 pane.addStyled(width, agentDashboardMutedStyle, "no routed HTTP paths")
1653 return
1654 }
1655
1656 shown := 0
1657 for _, route := range tunnel.HTTPRoutes {
1658 if shown >= 4 {
1659 break
1660 }
1661 if len(pane.lines)-startLine >= height {
1662 return
1663 }
1664 pane.addText(width, agentDashboardHTTPRouteSummary(route))
1665 shown++
1666 }
1667 if len(tunnel.HTTPRoutes) > shown && len(pane.lines)-startLine < height {
1668 pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+%d more routes", len(tunnel.HTTPRoutes)-shown))
1669 }
1670 if paidRouteCount == 0 && len(pane.lines)-startLine < height {
1671 pane.addStyled(width, agentDashboardMutedStyle, "no paid routes")
1672 }
1673 }
1674
1675 func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
1676 if height <= 0 {
1677 return
1678 }
1679 route := m.displayedRoute(tunnel)
1680 relay, hasRelay := m.selectedRelayStatus()
1681 inRoute := hasRelay && slices.Contains(route, relay.RelayURL)
1682 canAdd := hasRelay && relay.SupportsOverlay && !inRoute
1683
1684 startLine := len(pane.lines)
1685 pane.addSectionTitle(width, agentDashboardPaneMultiHop, "Multi-hop", m.activePane == agentDashboardPaneMultiHop)
1686 pane.addButtons(width,
1687 agentDashboardButton{label: "Add Hop", action: agentDashboardActionAddHop, disabled: !canAdd},
1688 agentDashboardButton{label: "Apply", action: agentDashboardActionApplyHop, disabled: len(route) < 2},
1689 agentDashboardButton{label: "Clear", action: agentDashboardActionClearHop, disabled: len(route) == 0},
1690 )
1691
1692 if hasRelay {
1693 pane.addText(width, "Selected relay: "+relayDashboardURL(relay))
1694 } else {
1695 pane.addStyled(width, agentDashboardMutedStyle, "no relays")
1696 }
1697
1698 routeLabel := "Multi-hop:"
1699 if m.draftTunnelID == tunnel.ID {
1700 routeLabel += " draft"
1701 }
1702 if len(route) == 0 {
1703 routeLabel = "Multi-hop: none"
1704 }
1705 pane.addText(width, routeLabel)
1706 for i, relayURL := range route {
1707 if len(pane.lines)-startLine >= height {
1708 return
1709 }
1710 pane.addText(width, fmt.Sprintf("%d. %s", i+1, relayURL))
1711 }
1712 }
1713
1714 func (v *agentDashboardView) addLine(line string) {
1715 v.lines = append(v.lines, line)
1716 }
1717
1718 func (v *agentDashboardView) addText(width int, text string) {
1719 v.addLine(agentDashboardFit(text, width))
1720 }
1721
1722 func (v *agentDashboardView) addStyled(width int, style lipgloss.Style, text string) {
1723 v.addLine(style.Render(agentDashboardFit(text, width)))
1724 }
1725
1726 func (v *agentDashboardView) addSidebarTitle(width int, title string) {
1727 label := agentDashboardFit(strings.TrimSpace(title), width)
1728 line := agentDashboardLabelStyle.Bold(true).Render(label)
1729 if ruleWidth := width - lipgloss.Width(label); ruleWidth > 0 {
1730 line += agentDashboardRuleStyle.Render(strings.Repeat("-", ruleWidth))
1731 }
1732 v.addLine(line)
1733 }
1734
1735 func (v *agentDashboardView) addMeta(width, offset int, label, value string) {
1736 value = strings.TrimSpace(value)
1737 if value == "" {
1738 value = "-"
1739 }
1740 labelText := agentDashboardLabelStyle.Render(agentDashboardCell(label+":", 9))
1741 valueText := agentDashboardMutedStyle.Render(agentDashboardWindow(value, offset, max(1, width-10)))
1742 v.addLine(labelText + " " + valueText)
1743 }
1744
1745 func (v *agentDashboardView) addSectionTitle(width int, pane agentDashboardPane, title string, active bool) {
1746 if width <= 0 {
1747 width = 1
1748 }
1749 style := agentDashboardSectionStyle
1750 if active {
1751 style = agentDashboardSelectedStyle
1752 }
1753 label := agentDashboardFit(" "+title+" ", width)
1754 labelWidth := lipgloss.Width(label)
1755 line := style.Render(label)
1756 if ruleWidth := width - labelWidth; ruleWidth > 0 {
1757 line += agentDashboardRuleStyle.Render(strings.Repeat("-", ruleWidth))
1758 }
1759 y := len(v.lines)
1760 v.lines = append(v.lines, line)
1761 v.regions = append(v.regions, agentDashboardRegion{
1762 x0: 0,
1763 x1: min(labelWidth, width),
1764 y: y,
1765 action: agentDashboardActionSelectPane,
1766 pane: pane,
1767 })
1768 }
1769
1770 func (v *agentDashboardView) addButtons(width int, buttons ...agentDashboardButton) {
1771 lines, regions := agentDashboardRenderButtons(width, len(v.lines), 0, buttons...)
1772 v.lines = append(v.lines, lines...)
1773 v.regions = append(v.regions, regions...)
1774 }
1775
1776 func (v *agentDashboardView) addView(child agentDashboardView) {
1777 startY := len(v.lines)
1778 v.lines = append(v.lines, child.lines...)
1779 for _, region := range child.regions {
1780 region.y += startY
1781 v.regions = append(v.regions, region)
1782 }
1783 }
1784
1785 func agentDashboardJoinHorizontal(left, right agentDashboardView, leftWidth, gutter, totalWidth, height int) agentDashboardView {
1786 rows := max(len(left.lines), len(right.lines))
1787 if height > 0 {
1788 rows = height
1789 }
1790 var out agentDashboardView
1791 out.lines = make([]string, 0, rows)
1792 gap := strings.Repeat(" ", gutter)
1793 rightWidth := max(1, totalWidth-leftWidth-gutter)
1794 for i := range rows {
1795 leftLine, rightLine := "", ""
1796 if i < len(left.lines) {
1797 leftLine = left.lines[i]
1798 }
1799 if i < len(right.lines) {
1800 rightLine = right.lines[i]
1801 }
1802 out.lines = append(out.lines,
1803 agentDashboardPadLine(leftLine, leftWidth)+gap+agentDashboardPadLine(rightLine, rightWidth),
1804 )
1805 }
1806 for _, region := range left.regions {
1807 if height <= 0 || region.y < height {
1808 out.regions = append(out.regions, region)
1809 }
1810 }
1811 for _, region := range right.regions {
1812 if height > 0 && region.y >= height {
1813 continue
1814 }
1815 region.x0 += leftWidth + gutter
1816 region.x1 += leftWidth + gutter
1817 out.regions = append(out.regions, region)
1818 }
1819 return out
1820 }
1821
1822 func (v *agentDashboardView) addTunnelRow(width, rowWidth int, tunnel types.AgentTunnelStatus, selected bool) {
1823 if width <= 0 {
1824 width = 1
1825 }
1826 rowWidth = min(rowWidth, max(1, width))
1827 line := agentDashboardTunnelRow(rowWidth, tunnel.State, tunnel.TargetAddr, tunnelDashboardName(tunnel))
1828 style := agentDashboardTunnelStyle(selected, tunnel.State)
1829 y := len(v.lines)
1830 v.lines = append(v.lines, style.Width(rowWidth).Render(agentDashboardFit(line, rowWidth)))
1831 v.regions = append(v.regions, agentDashboardRegion{
1832 x0: 0,
1833 x1: rowWidth,
1834 y: y,
1835 action: agentDashboardActionSelectTunnel,
1836 tunnel: tunnel.ID,
1837 })
1838 }
1839
1840 func (v *agentDashboardView) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardAction, tunnel, relay string) {
1841 plain := agentDashboardFit(line, width)
1842 y := len(v.lines)
1843 v.lines = append(v.lines, style.Width(width).Render(plain))
1844 v.regions = append(v.regions, agentDashboardRegion{
1845 x0: 0,
1846 x1: width,
1847 y: y,
1848 action: action,
1849 tunnel: tunnel,
1850 relay: relay,
1851 })
1852 }
1853
1854 func (v *agentDashboardView) addSettingsInputRow(width int, label string, input textinput.Model, field int, focused bool) {
1855 if width <= 0 {
1856 width = 1
1857 }
1858 labelStyle := agentDashboardMutedStyle
1859 if focused {
1860 labelStyle = agentDashboardInputStyle
1861 }
1862 labelText := agentDashboardCell(label+":", 12)
1863 y := len(v.lines)
1864 v.lines = append(v.lines, labelStyle.Render(labelText)+" "+input.View())
1865 v.regions = append(v.regions, agentDashboardRegion{
1866 x0: 0,
1867 x1: width,
1868 y: y,
1869 action: agentDashboardActionFocusSettingsField,
1870 field: field,
1871 })
1872 }
1873
1874 func (v *agentDashboardView) addAddTunnelInputRow(width int, label string, input textinput.Model, field int, focused bool) {
1875 if width <= 0 {
1876 width = 1
1877 }
1878 labelStyle := agentDashboardMutedStyle
1879 if focused {
1880 labelStyle = agentDashboardInputStyle
1881 }
1882 labelText := agentDashboardCell(label+":", 12)
1883 y := len(v.lines)
1884 v.lines = append(v.lines, labelStyle.Render(labelText)+" "+input.View())
1885 v.regions = append(v.regions, agentDashboardRegion{
1886 x0: 0,
1887 x1: width,
1888 y: y,
1889 action: agentDashboardActionFocusAddTunnelField,
1890 field: field,
1891 })
1892 }
1893
1894 func (v *agentDashboardView) clip(height int) {
1895 if height <= 0 || len(v.lines) <= height {
1896 return
1897 }
1898 v.lines = v.lines[:height]
1899 regions := v.regions[:0]
1900 for _, region := range v.regions {
1901 if region.y < height {
1902 regions = append(regions, region)
1903 }
1904 }
1905 v.regions = regions
1906 }
1907
1908 func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButton) ([]string, []agentDashboardRegion) {
1909 if width <= 0 {
1910 width = 1
1911 }
1912 var line strings.Builder
1913 var lines []string
1914 var regions []agentDashboardRegion
1915 lineY := y
1916 lineX := x
1917 for i, button := range buttons {
1918 plain := "[ " + button.label + " ]"
1919 if lipgloss.Width(plain) > width {
1920 plain = agentDashboardFit(plain, width)
1921 }
1922 plainWidth := lipgloss.Width(plain)
1923 space := 0
1924 if i > 0 && line.Len() > 0 {
1925 space = 1
1926 }
1927 if line.Len() > 0 && lineX+space+plainWidth > width {
1928 lines = append(lines, line.String())
1929 line.Reset()
1930 lineY++
1931 lineX = x
1932 space = 0
1933 }
1934 if space > 0 {
1935 line.WriteString(" ")
1936 lineX++
1937 }
1938 style := agentDashboardButtonStyle
1939 if button.disabled {
1940 style = agentDashboardDisabledStyle
1941 } else {
1942 regions = append(regions, agentDashboardRegion{
1943 x0: lineX,
1944 x1: min(lineX+plainWidth, width),
1945 y: lineY,
1946 action: button.action,
1947 })
1948 }
1949 line.WriteString(style.Render(plain))
1950 lineX += plainWidth
1951 }
1952 if line.Len() > 0 || len(lines) == 0 {
1953 lines = append(lines, line.String())
1954 }
1955 return lines, regions
1956 }
1957
1958 func agentDashboardColumnWidths(width int) (int, int, int) {
1959 if width <= 0 {
1960 width = 88
1961 }
1962 gutter := agentDashboardSidebarGutter
1963 if width <= gutter+2 {
1964 gutter = 0
1965 }
1966 sidebarWidth := min(agentDashboardSidebarWidth, max(1, width-gutter-1))
1967 mainWidth := max(1, width-sidebarWidth-gutter)
1968 return mainWidth, gutter, sidebarWidth
1969 }
1970
1971 func agentDashboardMetaWidth(value string) int {
1972 value = strings.TrimSpace(value)
1973 if value == "" {
1974 value = "-"
1975 }
1976 return 10 + lipgloss.Width(value)
1977 }
1978
1979 func defaultDashboardBodyHeight(height int) int {
1980 bodyHeight := height - 8
1981 if height <= 0 {
1982 bodyHeight = 22
1983 }
1984 return max(bodyHeight, 1)
1985 }
1986
1987 func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
1988 if selected {
1989 return agentDashboardSelectedStyle
1990 }
1991 switch strings.ToLower(strings.TrimSpace(state)) {
1992 case "running":
1993 return agentDashboardOKStyle
1994 case "starting":
1995 return agentDashboardPendingStyle
1996 case "error":
1997 return agentDashboardErrorStyle
1998 default:
1999 return agentDashboardMutedStyle
2000 }
2001 }
2002
2003 func agentDashboardRelayStyle(selected bool, tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus, failed, connecting bool) lipgloss.Style {
2004 if selected {
2005 return agentDashboardSelectedStyle
2006 }
2007 if relay.Banned {
2008 return agentDashboardErrorStyle
2009 }
2010 if failed {
2011 return agentDashboardErrorStyle
2012 }
2013 if relayDashboardConnected(tunnel, relay) {
2014 return agentDashboardOKStyle
2015 }
2016 if connecting {
2017 return agentDashboardPendingStyle
2018 }
2019 return agentDashboardMutedStyle
2020 }
2021
2022 func relayDashboardActive(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
2023 return relayDashboardConnected(tunnel, relay) || relay.Connecting
2024 }
2025
2026 func relayDashboardConnected(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
2027 return relay.PublicURL != "" || slices.Contains(tunnel.MultiHop, relay.RelayURL)
2028 }
2029
2030 func agentDashboardHTTPRouteSummary(route types.AgentHTTPRoute) string {
2031 prefix := strings.TrimSpace(route.Prefix)
2032 if prefix == "" {
2033 prefix = "-"
2034 }
2035 upstream := strings.TrimSpace(route.Upstream)
2036 if upstream == "" {
2037 upstream = "-"
2038 }
2039 if amount := strings.TrimSpace(route.Amount); amount != "" {
2040 methods := "ALL"
2041 if len(route.Methods) > 0 {
2042 var normalized []string
2043 for _, raw := range route.Methods {
2044 if method := strings.ToUpper(strings.TrimSpace(raw)); method != "" {
2045 normalized = append(normalized, method)
2046 }
2047 }
2048 if len(normalized) > 0 {
2049 methods = strings.Join(normalized, ",")
2050 }
2051 }
2052 return fmt.Sprintf("%s -> %s (%s %s)", prefix, upstream, methods, amount)
2053 }
2054 return prefix + " -> " + upstream
2055 }
2056
2057 func agentDashboardX402Network(testnet bool) string {
2058 if testnet {
2059 return "sui:testnet"
2060 }
2061 return "sui:mainnet"
2062 }
2063
2064 func (m agentDashboardModel) settingsChanged(tunnel types.AgentTunnelStatus) bool {
2065 if m.settingsEditTunnelID != tunnel.ID {
2066 return false
2067 }
2068 maxRelays, err := strconv.Atoi(strings.TrimSpace(m.settingsMaxRelays.Value()))
2069 if err != nil {
2070 return true
2071 }
2072 hide, err := strconv.ParseBool(utils.StringOrDefault(strings.TrimSpace(m.metadataHide.Value()), "false"))
2073 if err != nil {
2074 return true
2075 }
2076 metadata := tunnel.Metadata
2077 return maxRelays != tunnel.MaxActiveRelays ||
2078 strings.TrimSpace(m.metadataDescription.Value()) != strings.TrimSpace(metadata.Description) ||
2079 !slices.Equal(utils.SplitCSV(m.metadataTags.Value()), metadata.Tags) ||
2080 strings.TrimSpace(m.metadataOwner.Value()) != strings.TrimSpace(metadata.Owner) ||
2081 strings.TrimSpace(m.metadataThumbnail.Value()) != strings.TrimSpace(metadata.Thumbnail) ||
2082 hide != metadata.Hide
2083 }
2084
2085 func relayDashboardFeatures(relay types.AgentRelayStatus) string {
2086 var features []string
2087 if relay.SupportsOverlay {
2088 features = append(features, "overlay")
2089 }
2090 if relay.SupportsUDP {
2091 features = append(features, "udp")
2092 }
2093 if relay.SupportsTCP {
2094 features = append(features, "tcp")
2095 }
2096 if len(features) == 0 {
2097 return "-"
2098 }
2099 return strings.Join(features, ",")
2100 }
2101
2102 func relayDashboardVersion(relay types.AgentRelayStatus) string {
2103 if version := strings.TrimSpace(relay.Version); version != "" {
2104 return version
2105 }
2106 return "-"
2107 }
2108
2109 func relayDashboardURL(relay types.AgentRelayStatus) string {
2110 if publicURL := relayDashboardPublicURL(relay.PublicURL); publicURL != "" {
2111 return publicURL
2112 }
2113 return relayDashboardRelayLabel(relay.RelayURL)
2114 }
2115
2116 func relayDashboardPublicURL(rawURL string) string {
2117 rawURL = strings.TrimSpace(rawURL)
2118 if rawURL == "" {
2119 return ""
2120 }
2121 parsed, err := url.Parse(rawURL)
2122 if err != nil || parsed.Scheme == "" || parsed.Host == "" {
2123 return rawURL
2124 }
2125 host := parsed.Hostname()
2126 if host == "" {
2127 host = parsed.Host
2128 }
2129 if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
2130 host = "[" + host + "]"
2131 }
2132 parsed.Host = host
2133 parsed.User = nil
2134 parsed.RawQuery = ""
2135 parsed.Fragment = ""
2136 return parsed.String()
2137 }
2138
2139 func relayDashboardRelayLabel(rawURL string) string {
2140 rawURL = strings.TrimSpace(rawURL)
2141 if rawURL == "" {
2142 return "-"
2143 }
2144 parsed, err := url.Parse(rawURL)
2145 if err != nil || parsed.Host == "" {
2146 return rawURL
2147 }
2148 host := strings.TrimSpace(parsed.Hostname())
2149 if host == "" {
2150 return strings.TrimSpace(parsed.Host)
2151 }
2152 return host
2153 }
2154
2155 func (m agentDashboardModel) relayDashboardMode(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) string {
2156 var modes []string
2157 if relay.PublicURL != "" {
2158 modes = append(modes, "direct")
2159 } else if relay.Connecting || m.relayDashboardConnecting(tunnel, relay) {
2160 modes = append(modes, "connecting...")
2161 } else if m.relayDashboardFailed(tunnel, relay) {
2162 modes = append(modes, "failed")
2163 }
2164 for i, relayURL := range tunnel.MultiHop {
2165 if relayURL != relay.RelayURL {
2166 continue
2167 }
2168 if i == 0 {
2169 modes = append(modes, "hop-entry")
2170 } else {
2171 modes = append(modes, "hop-relay")
2172 }
2173 break
2174 }
2175 if len(modes) > 0 {
2176 return strings.Join(modes, ",")
2177 }
2178 return "-"
2179 }
2180
2181 func tunnelDashboardName(tunnel types.AgentTunnelStatus) string {
2182 if strings.TrimSpace(tunnel.Name) != "" {
2183 return tunnel.Name
2184 }
2185 return tunnel.ID
2186 }
2187
2188 func tunnelDashboardTarget(tunnel types.AgentTunnelStatus) string {
2189 if target := strings.TrimSpace(tunnel.TargetAddr); target != "" {
2190 return target
2191 }
2192 if len(tunnel.HTTPRoutes) == 0 {
2193 return "-"
2194 }
2195 paid := 0
2196 for _, route := range tunnel.HTTPRoutes {
2197 if strings.TrimSpace(route.Amount) != "" {
2198 paid++
2199 }
2200 }
2201 if paid == 0 {
2202 return fmt.Sprintf("%d routes", len(tunnel.HTTPRoutes))
2203 }
2204 return fmt.Sprintf("%d routes, %d paid", len(tunnel.HTTPRoutes), paid)
2205 }
2206
2207 func agentDashboardTunnelTableWidth(width int, tunnels []types.AgentTunnelStatus) int {
2208 tableWidth := 56
2209 for _, tunnel := range tunnels {
2210 nameWidth := max(lipgloss.Width(tunnelDashboardName(tunnel)), lipgloss.Width("TUNNEL"))
2211 targetWidth := max(lipgloss.Width(tunnelDashboardTarget(tunnel)), lipgloss.Width("TARGET"))
2212 tableWidth = max(tableWidth, 11+1+targetWidth+1+nameWidth)
2213 }
2214 return max(1, min(tableWidth, width))
2215 }
2216
2217 func agentDashboardTunnelRow(width int, state, target, name string) string {
2218 if width < 28 {
2219 return agentDashboardFit(state+" "+name, width)
2220 }
2221 if width < 56 {
2222 stateW := 11
2223 return agentDashboardCell(state, stateW) + " " +
2224 agentDashboardFit(name, width-stateW-1)
2225 }
2226 stateW := 11
2227 targetW := 22
2228 nameW := max(1, width-stateW-targetW-2)
2229 return agentDashboardCell(state, stateW) + " " +
2230 agentDashboardCell(target, targetW) + " " +
2231 agentDashboardFit(name, nameW)
2232 }
2233
2234 func agentDashboardRelayWindow(selected, total, rows int) (int, int) {
2235 if total <= 0 || rows <= 0 {
2236 return 0, 0
2237 }
2238 if rows >= total {
2239 return 0, total
2240 }
2241 if selected < 0 {
2242 selected = 0
2243 }
2244 if selected >= total {
2245 selected = total - 1
2246 }
2247 start := selected - rows/2
2248 if start < 0 {
2249 start = 0
2250 }
2251 if start+rows > total {
2252 start = total - rows
2253 }
2254 return start, start + rows
2255 }
2256
2257 func agentDashboardRelayRow(width int, mode, version, features, displayURL string) string {
2258 if width < 28 {
2259 modeW := lipgloss.Width(mode)
2260 urlW := max(1, width-modeW-1)
2261 return agentDashboardFit(mode+" "+agentDashboardURLCell(displayURL, urlW), width)
2262 }
2263 if width < 56 {
2264 modeW := 13
2265 return agentDashboardCell(mode, modeW) + " " +
2266 agentDashboardURLCell(displayURL, width-modeW-1)
2267 }
2268 modeW := 13
2269 versionW := 8
2270 featuresW := 15
2271 relayW := max(1, width-modeW-versionW-featuresW-3)
2272 return agentDashboardCell(mode, modeW) + " " +
2273 agentDashboardCell(version, versionW) + " " +
2274 agentDashboardCell(features, featuresW) + " " +
2275 agentDashboardURLCell(displayURL, relayW)
2276 }
2277
2278 func agentDashboardURLCell(value string, width int) string {
2279 value = strings.TrimSpace(value)
2280 if value == "" || width <= 0 {
2281 return ""
2282 }
2283 if lipgloss.Width(value) <= width {
2284 return value
2285 }
2286 parsed, err := url.Parse(value)
2287 if err == nil && parsed.Scheme != "" && parsed.Host != "" {
2288 host := strings.TrimSpace(parsed.Hostname())
2289 if host == "" {
2290 host = strings.TrimSpace(parsed.Host)
2291 }
2292 if host != "" {
2293 return agentDashboardFit("open "+host, width)
2294 }
2295 }
2296 return agentDashboardFit(value, width)
2297 }
2298
2299 func openDashboardURL(rawURL string) error {
2300 rawURL = strings.TrimSpace(rawURL)
2301 parsed, err := url.Parse(rawURL)
2302 if err != nil {
2303 return err
2304 }
2305 switch strings.ToLower(parsed.Scheme) {
2306 case "http", "https":
2307 default:
2308 return fmt.Errorf("unsupported url scheme %q", parsed.Scheme)
2309 }
2310 if strings.TrimSpace(parsed.Host) == "" {
2311 return fmt.Errorf("url host is required")
2312 }
2313
2314 var cmd *exec.Cmd
2315 ctx, cancel := context.WithCancel(context.Background())
2316 defer cancel()
2317
2318 switch runtime.GOOS {
2319 case "windows":
2320 cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", rawURL)
2321 case "darwin":
2322 cmd = exec.CommandContext(ctx, "open", rawURL)
2323 default:
2324 cmd = exec.CommandContext(ctx, "xdg-open", rawURL)
2325 }
2326 if err := cmd.Start(); err != nil {
2327 return err
2328 }
2329 go func() {
2330 _ = cmd.Wait()
2331 }()
2332 return nil
2333 }
2334
2335 func agentDashboardFit(value string, width int) string {
2336 value = strings.TrimSpace(value)
2337 if value == "" || width <= 0 {
2338 return ""
2339 }
2340 if lipgloss.Width(value) <= width {
2341 return value
2342 }
2343 if width == 1 {
2344 return "~"
2345 }
2346 var out strings.Builder
2347 used := 0
2348 for _, r := range value {
2349 cellWidth := lipgloss.Width(string(r))
2350 if used+cellWidth > width-1 {
2351 break
2352 }
2353 out.WriteRune(r)
2354 used += cellWidth
2355 }
2356 return out.String() + "~"
2357 }
2358
2359 func agentDashboardWindow(value string, offset, width int) string {
2360 value = strings.ReplaceAll(strings.TrimSpace(value), "\t", " ")
2361 if value == "" || width <= 0 {
2362 return ""
2363 }
2364 offset = max(0, offset)
2365 if offset == 0 && lipgloss.Width(value) <= width {
2366 return value
2367 }
2368 var out strings.Builder
2369 skipped := 0
2370 used := 0
2371 for _, r := range value {
2372 cellWidth := lipgloss.Width(string(r))
2373 if skipped+cellWidth <= offset {
2374 skipped += cellWidth
2375 continue
2376 }
2377 if skipped < offset {
2378 skipped += cellWidth
2379 continue
2380 }
2381 if used+cellWidth > width {
2382 break
2383 }
2384 out.WriteRune(r)
2385 used += cellWidth
2386 }
2387 return out.String()
2388 }
2389
2390 func agentDashboardPadLine(line string, width int) string {
2391 if pad := width - lipgloss.Width(line); pad > 0 {
2392 return line + strings.Repeat(" ", pad)
2393 }
2394 return line
2395 }
2396
2397 func agentDashboardCell(value string, width int) string {
2398 value = agentDashboardFit(value, width)
2399 if lipgloss.Width(value) >= width {
2400 return value
2401 }
2402 return value + strings.Repeat(" ", width-lipgloss.Width(value))
2403 }