- Wizard: type pick → cloud region → token → node config (huh forms) - Dashboard: node list with status, action shortcuts - Auto-routes based on ~/.wallarm/state.json existence - Replaces text stubs with proper interactive TUI
327 lines
9 KiB
Go
327 lines
9 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"
|
|
"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
|
|
}
|