feat: bubbletea TUI — wizard + dashboard
- 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
This commit is contained in:
parent
1a2a0fbd7b
commit
dfd7180556
5 changed files with 454 additions and 91 deletions
|
|
@ -15,8 +15,8 @@ import (
|
|||
"os"
|
||||
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/tunnel"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/ui"
|
||||
)
|
||||
|
||||
// Embedded tunnel key — set at build time with:
|
||||
|
|
@ -62,15 +62,22 @@ deployment. On subsequent runs, it shows your existing deployments.
|
|||
return
|
||||
}
|
||||
|
||||
// ── Default: preflight → detect state → route ────────────────
|
||||
// ── Default: preflight → TUI wizard/dashboard ────────────────
|
||||
fmt.Println("═══ Wallarm Deployment Manager ═══")
|
||||
fmt.Println()
|
||||
|
||||
// 1. Preflight checks (always run on start)
|
||||
fmt.Println("Running preflight checks...")
|
||||
result := preflight.Run()
|
||||
|
||||
printPreflight(result)
|
||||
for _, c := range result.Checks {
|
||||
marker := "✅"
|
||||
if !c.Passed {
|
||||
marker = "❌"
|
||||
} else if c.Warning {
|
||||
marker = "⚠️"
|
||||
}
|
||||
fmt.Printf(" %s %s — %s\n", marker, c.Name, c.Detail)
|
||||
}
|
||||
|
||||
if !result.Passed {
|
||||
fmt.Println("\n❌ Preflight checks failed. Fix the issues above and re-run.")
|
||||
|
|
@ -79,83 +86,9 @@ deployment. On subsequent runs, it shows your existing deployments.
|
|||
fmt.Println("✅ Preflight checks passed.")
|
||||
fmt.Println()
|
||||
|
||||
// 2. Detect existing deployment
|
||||
if state.HasDeployment() {
|
||||
showDashboard()
|
||||
} else {
|
||||
showWizard(result)
|
||||
}
|
||||
}
|
||||
|
||||
func printPreflight(r preflight.Result) {
|
||||
for _, c := range r.Checks {
|
||||
marker := "✅"
|
||||
desc := ""
|
||||
if !c.Passed {
|
||||
marker = "❌"
|
||||
desc = " — " + c.Detail
|
||||
} else if c.Warning {
|
||||
marker = "⚠️"
|
||||
desc = " — " + c.Detail
|
||||
} else {
|
||||
desc = " — " + c.Detail
|
||||
}
|
||||
fmt.Printf(" %s %s%s\n", marker, c.Name, desc)
|
||||
}
|
||||
}
|
||||
|
||||
func showDashboard() {
|
||||
fmt.Println("📊 Existing deployment detected.")
|
||||
fmt.Println()
|
||||
s, err := state.Load()
|
||||
if err != nil || s == nil {
|
||||
fmt.Println("Could not load state. Starting fresh.")
|
||||
showWizard(preflight.Result{Passed: true})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Deployment type: %s\n", s.DeploymentType)
|
||||
fmt.Printf("Cloud region: %s (%s)\n", s.CloudRegion, s.APIHost)
|
||||
fmt.Println()
|
||||
fmt.Println("Nodes:")
|
||||
for i, n := range s.Nodes {
|
||||
status := "●"
|
||||
if n.Status != "running" {
|
||||
status = "○"
|
||||
}
|
||||
fmt.Printf(" %s %d. %s — %s", status, i+1, n.Name, n.Status)
|
||||
if n.Address != "" {
|
||||
fmt.Printf(" (%s)", n.Address)
|
||||
}
|
||||
if n.Port != 0 {
|
||||
fmt.Printf(" :%d", n.Port)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("[a]dd node [c]onfigure [r]emove node [t]unnel [q]uit")
|
||||
fmt.Println()
|
||||
fmt.Println("Dashboard actions coming soon — use the existing bash scripts for configuration.")
|
||||
}
|
||||
|
||||
func showWizard(r preflight.Result) {
|
||||
fmt.Println("No existing deployment found. Starting setup wizard...")
|
||||
fmt.Println()
|
||||
fmt.Println("Choose deployment type:")
|
||||
fmt.Println(" 1) Docker — Wallarm node as a container")
|
||||
fmt.Println(" 2) Native — Wallarm node directly on this OS (no Docker)")
|
||||
fmt.Println()
|
||||
fmt.Println("Wizard UI coming soon — use the bash scripts in deploy/ for now.")
|
||||
fmt.Println()
|
||||
if r.USReachable || r.EUReachable {
|
||||
fmt.Print("Cloud reachable: ")
|
||||
if r.USReachable {
|
||||
fmt.Print("US ✓ ")
|
||||
}
|
||||
if r.EUReachable {
|
||||
fmt.Print("EU ✓")
|
||||
}
|
||||
fmt.Println()
|
||||
// 2. Launch bubbletea TUI (handles wizard vs dashboard routing)
|
||||
if err := ui.Run(result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "UI error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
go.mod
37
go.mod
|
|
@ -1,7 +1,38 @@
|
|||
module git.sechpoint.app/customer-engineering/wallarm
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/crypto v0.36.0
|
||||
toolchain go1.24.4
|
||||
|
||||
require golang.org/x/sys v0.31.0 // indirect
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
|
|
|||
75
go.sum
75
go.sum
|
|
@ -1,6 +1,77 @@
|
|||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
|
|
|
|||
327
internal/ui/ui.go
Normal file
327
internal/ui/ui.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// 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
|
||||
}
|
||||
9
setup.sh
9
setup.sh
|
|
@ -168,10 +168,11 @@ for deploy_type in "${DEPLOY_TYPES[@]}"; do
|
|||
case "$deploy_type" in
|
||||
docker)
|
||||
echo -e "${CYAN}Docker deployment next steps:${NC}"
|
||||
echo -e " 1. Run the preflight check: ${YELLOW}./deploy/wallarm-ct-check.sh${NC}"
|
||||
echo -e " 2. Deploy a Wallarm node: ${YELLOW}./deploy/wallarm-ct-deploy.sh${NC}"
|
||||
echo -e " 3. Reconfigure existing node: ${YELLOW}./deploy/wallarm-ct-reconfigure.sh${NC}"
|
||||
echo -e " 4. Uninstall a node: ${YELLOW}./deploy/wallarm-ct-uninstall.sh${NC}"
|
||||
echo -e " 1. Run the preflight check: ${YELLOW}./deploy/wallarm-docker.sh --preflight${NC}"
|
||||
echo -e " 2. Deploy a Wallarm node: ${YELLOW}sudo ./deploy/wallarm-docker.sh --install${NC}"
|
||||
echo -e " 3. Reconfigure existing node: ${YELLOW}sudo ./deploy/wallarm-docker.sh --config${NC}"
|
||||
echo -e " 4. Uninstall a node: ${YELLOW}sudo ./deploy/wallarm-docker.sh --remove${NC}"
|
||||
echo -e " 5. Show node status: ${YELLOW}./deploy/wallarm-docker.sh --status${NC}"
|
||||
echo
|
||||
;;
|
||||
native)
|
||||
|
|
|
|||
Loading…
Reference in a new issue