// Package ui provides the bubbletea terminal UI for wallarm: // - Wizard: guides new deployments (type → region → config → deploy) // - Dashboard: lists existing nodes with actions (add, config, remove, tunnel) package ui import ( "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "git.sechpoint.app/customer-engineering/wallarm/internal/preflight" "git.sechpoint.app/customer-engineering/wallarm/internal/state" ) // Styles var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1) goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("227")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) ) // Model is the top-level bubbletea model. type Model struct { state stateView width int height int } type stateView int const ( viewPreflight stateView = iota viewRoute viewWizard viewDashboard viewDone ) var preflightResult preflight.Result // Run starts the bubbletea TUI. func Run(r preflight.Result) error { preflightResult = r m := Model{state: viewPreflight} m.state = viewRoute // skip preflight display since it already printed in main p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return err } return nil } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } switch m.state { case viewRoute: if state.HasDeployment() { m.state = viewDashboard } else { m.state = viewWizard } } return m, nil } func (m Model) View() string { switch m.state { case viewRoute: return "" case viewWizard: return wizardView(m) case viewDashboard: return dashboardView(m) case viewDone: return goodStyle.Render("Done. Run wallarm again to manage your deployment.") } return "" } // ─── Wizard ────────────────────────────────────────────────────────── func wizardView(m Model) string { s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n" s += dimStyle.Render("No existing deployment found. Let's set one up.") + "\n\n" // Step 1: Deployment type s += activeStyle.Render("Step 1: Choose deployment type") + "\n" s += " [1] Docker — Wallarm node as a container\n" s += " [2] Native — Wallarm node directly on this OS\n" s += dimStyle.Render(" Press 1 or 2, then Enter") + "\n\n" // Step 2: Cloud region r := preflightResult s += activeStyle.Render("Step 2: Choose cloud region") + "\n" if r.USReachable { s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n" } else { s += dimStyle.Render(" US — not reachable") + "\n" } if r.EUReachable { s += goodStyle.Render(" EU") + " — api.wallarm.com (reachable)\n" } else { s += dimStyle.Render(" EU — not reachable") + "\n" } // Step 3: Required info s += "\n" + activeStyle.Render("Step 3: Required information") + "\n" s += " • Wallarm API token (Deploy role)\n" s += " • Listen port / address\n" s += " • Upstream server (Docker only)\n\n" s += dimStyle.Render("Full interactive form coming in next iteration.") + "\n" s += dimStyle.Render("For now, use: sudo ./deploy/wallarm-native.sh --install") + "\n\n" s += dimStyle.Render("Press q to quit") return s } // ─── Dashboard ─────────────────────────────────────────────────────── func dashboardView(m Model) string { s, err := state.Load() if err != nil || s == nil { return badStyle.Render("Error loading state.") + "\nPress q to quit" } out := titleStyle.Render("📊 Wallarm Dashboard") + "\n" out += fmt.Sprintf("Type: %s | Cloud: %s (%s)\n", s.DeploymentType, s.CloudRegion, s.APIHost) out += dimStyle.Render(strings.Repeat("─", 50)) + "\n\n" if len(s.Nodes) == 0 { out += dimStyle.Render("No nodes deployed yet.") + "\n\n" } else { out += activeStyle.Render("Nodes:") + "\n" for _, n := range s.Nodes { marker := "●" style := goodStyle if n.Status != "running" { marker = "○" style = warnStyle } detail := n.Address if detail == "" && n.Port != 0 { detail = fmt.Sprintf(":%d → %s:%d", n.Port, n.UpstreamIP, n.UpstreamPort) } out += style.Render(fmt.Sprintf(" %s %s — %s", marker, n.Name, n.Status)) if detail != "" { out += dimStyle.Render(fmt.Sprintf(" (%s)", detail)) } out += "\n" } out += "\n" } out += "Actions:\n" out += fmt.Sprintf(" %s\n", activeStyle.Render("[a] Add node")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[c] Configure")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[r] Remove node")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[t] Start tunnel")) out += fmt.Sprintf(" %s\n", dimStyle.Render("[q] Quit")) return out } // HuhForm runs an interactive multi-step form (used programmatically, not via bubbletea TUI). func HuhForm(preflightResult preflight.Result) (state.State, error) { var s state.State // Step 1: Deployment type var deployType string err := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Choose deployment type"). Options( huh.NewOption("Docker — node as a container", "docker"), huh.NewOption("Native — node directly on OS (no Docker)", "native"), ). Value(&deployType), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } s.DeploymentType = deployType // Step 2: Cloud region regionOptions := []huh.Option[string]{} if preflightResult.USReachable { regionOptions = append(regionOptions, huh.NewOption("US (us1.api.wallarm.com)", "US")) } if preflightResult.EUReachable { regionOptions = append(regionOptions, huh.NewOption("EU (api.wallarm.com)", "EU")) } if len(regionOptions) == 0 { fmt.Println("No cloud regions reachable.") os.Exit(1) } if len(regionOptions) == 1 { // Auto-select the only reachable region for _, opt := range regionOptions { s.CloudRegion = opt.Key if s.CloudRegion == "US" { s.APIHost = "us1.api.wallarm.com" } else { s.APIHost = "api.wallarm.com" } break } } else { var region string err = huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Choose Wallarm cloud region"). Options(regionOptions...). Value(®ion), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } s.CloudRegion = region if region == "US" { s.APIHost = "us1.api.wallarm.com" } else { s.APIHost = "api.wallarm.com" } } // Step 3: Token var token string err = huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Wallarm API Token (Deploy role)"). Placeholder("Paste your token here"). EchoMode(huh.EchoModePassword). Value(&token). Validate(func(s string) error { if len(s) < 10 { return fmt.Errorf("token too short") } return nil }), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } // Step 4: Node configuration (varies by type) if deployType == "native" { var nodeName, address, labels string err = huh.NewForm( huh.NewGroup( huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(s string) error { if s == "" { return fmt.Errorf("node name required") } return nil }), huh.NewInput().Title("Listen address (IP:Port)").Placeholder("0.0.0.0:8081").Value(&address), huh.NewInput().Title("Labels (optional)").Placeholder("group=prod").Value(&labels), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } s.Nodes = append(s.Nodes, state.Node{ Name: nodeName, Type: "native", Address: address, Status: "pending", }) } else { // Docker: port, upstream IP, upstream port var nodeName, upstreamIP string var port, upstreamPort int err = huh.NewForm( huh.NewGroup( huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(s string) error { if s == "" { return fmt.Errorf("node name required") } return nil }), huh.NewInput().Title("Ingress port").Placeholder("80").Value( func() *string { v := "80"; return &v }(), ), huh.NewInput().Title("Upstream IP").Placeholder("192.168.1.100").Value(&upstreamIP), huh.NewInput().Title("Upstream port").Placeholder("8080").Value( func() *string { v := "8080"; return &v }(), ), ), ).WithTheme(huh.ThemeCharm()).Run() _ = port _ = upstreamPort _ = nodeName _ = upstreamIP if err != nil { return s, err } // TODO: wire actual port parsing } return s, nil }