wallarm/internal/ui/ui.go
admin ff5a09b994 refactor: drop Docker, single-binary native-only world
Removed:
- docker/ (scripts, binaries, images) - no longer needed
- native/, common/ bash scripts - replaced by Go binary
- setup.sh - replaced by deploy.sh
- internal/docker/ Go package - no longer needed

Added:
- deploy.sh - one-command bootstrap
- --version flag

Result: one binary, one TUI, one deployment type. Zero Docker.
Binary distributed via Gitea releases (not in repo).
2026-08-01 14:31:25 +00:00

339 lines
8.7 KiB
Go

// 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"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
"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
status string // feedback message during deployment
deploying bool // true while deployment is in progress
}
type stateView int
const (
viewPreflight stateView = iota
viewRoute
viewWizard
viewDashboard
viewDone
viewError
)
var preflightResult preflight.Result
// deployCompleteMsg is sent when deployment finishes.
type deployCompleteMsg struct {
err error
}
// Run starts the bubbletea TUI.
func Run(r preflight.Result) error {
preflightResult = r
m := Model{state: viewRoute}
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 "1", "2":
if m.state == viewWizard && !m.deploying {
return m, m.startDeploy()
}
case "enter":
if m.state == viewDone || m.state == viewError {
return m, tea.Quit
}
}
case deployCompleteMsg:
m.deploying = false
if msg.err != nil {
m.status = fmt.Sprintf("Deployment failed: %v", msg.err)
m.state = viewError
} else {
m.status = "Deployment successful!"
m.state = viewDone
}
return m, nil
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("✅ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.")
case viewError:
return badStyle.Render("❌ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.")
}
return ""
}
// startDeploy runs the Huh form in a goroutine, then deploys.
func (m Model) startDeploy() tea.Cmd {
return func() tea.Msg {
m.deploying = true
s, err := runDeployForm()
if err != nil {
return deployCompleteMsg{err}
}
// Deploy native node
err = deployNative(s)
if err == nil {
state.Save(&s)
}
return deployCompleteMsg{err}
}
}
// ─── Wizard ──────────────────────────────────────────────────────────
func wizardView(m Model) string {
s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n"
if m.deploying {
s += dimStyle.Render("Configuring deployment — follow the prompts below...") + "\n"
return s
}
s += dimStyle.Render("No existing deployment found. Let's set one up.") + "\n\n"
s += activeStyle.Render("Wallarm Native Node Deployment") + "\n"
s += " Press Enter to begin configuration" + "\n\n"
s += activeStyle.Render("Step 2: Cloud region") + "\n"
r := preflightResult
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"
}
s += dimStyle.Render("\nPress q to quit")
return s
}
// runDeployForm collects configuration via huh interactive forms.
func runDeployForm() (state.State, error) {
var s state.State
s.DeploymentType = "native"
// Step 1: 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 {
return s, fmt.Errorf("no cloud regions reachable")
}
var region string
if len(regionOptions) == 1 {
region = regionOptions[0].Value // auto-select
} else {
err := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Choose Wallarm cloud region").
Options(regionOptions...).
Value(&region),
),
).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 2: API Token
var apiToken string
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Wallarm API Token (Deploy role)").
Placeholder("Paste your token here").
EchoMode(huh.EchoModePassword).
Value(&apiToken).
Validate(func(v string) error {
if len(v) < 10 {
return fmt.Errorf("token too short")
}
return nil
}),
),
).WithTheme(huh.ThemeCharm()).Run()
if err != nil {
return s, err
}
s.APIToken = apiToken
// Step 3: Node configuration
var nodeName, address, labels string
nodeForm := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(v string) error {
if v == "" {
return fmt.Errorf("required")
}
return nil
}),
huh.NewInput().Title("Listen address (IP:Port)").Placeholder("0.0.0.0:8081").Value(&address).Validate(func(v string) error {
if v == "" {
return fmt.Errorf("required")
}
return nil
}),
huh.NewInput().Title("Labels (optional)").Placeholder("group=prod").Value(&labels),
),
)
err = nodeForm.WithTheme(huh.ThemeCharm()).Run()
if err != nil {
return s, err
}
s.Nodes = append(s.Nodes, state.Node{
Name: nodeName,
Type: "native",
Address: address,
Status: "deploying",
CreatedAt: time.Now().Format(time.RFC3339),
})
return s, nil
}
// deployNative executes the native deployment.
func deployNative(s state.State) error {
if err := native.CreateNodesDir(); err != nil {
return err
}
if err := native.GenerateSystemdTemplate(); err != nil {
return err
}
for _, node := range s.Nodes {
labels := "group=" + node.Name
if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil {
return err
}
}
return nil
}
// ─── 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
}