fix: move deploy form out of TUI into plain terminal
Bubbletea and huh cannot coexist in the same process. Fixed by: - TUI handles only display: deploy choice + dashboard - Deploy wizard runs as plain terminal prompts in main.go - No more terminal conflicts, keys work correctly - Same flow: wallarm → choice → form → deploy → state saved
This commit is contained in:
parent
e378c2a436
commit
ed9655ee79
2 changed files with 134 additions and 231 deletions
|
|
@ -15,8 +15,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
|
||||||
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
"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/tunnel"
|
||||||
"git.sechpoint.app/customer-engineering/wallarm/internal/ui"
|
"git.sechpoint.app/customer-engineering/wallarm/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
@ -27,7 +30,108 @@ var tunnelKey string
|
||||||
// Version set at build time.
|
// Version set at build time.
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
// runTunnelFlow prompts for tunnel credentials in plain terminal and starts the tunnel.
|
// runDeployFlow runs the deployment wizard in plain terminal (no bubbletea).
|
||||||
|
func runDeployFlow(r preflight.Result) {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
var s state.State
|
||||||
|
s.DeploymentType = "native"
|
||||||
|
|
||||||
|
// Step 1: Cloud region
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("─── Cloud Region ───")
|
||||||
|
if r.USReachable && r.EUReachable {
|
||||||
|
fmt.Println(" [1] US (us1.api.wallarm.com)")
|
||||||
|
fmt.Println(" [2] EU (api.wallarm.com)")
|
||||||
|
fmt.Print("Choose region [1/2]: ")
|
||||||
|
choice, _ := reader.ReadString('\n')
|
||||||
|
choice = strings.TrimSpace(choice)
|
||||||
|
if choice == "2" {
|
||||||
|
s.CloudRegion = "EU"
|
||||||
|
s.APIHost = "api.wallarm.com"
|
||||||
|
} else {
|
||||||
|
s.CloudRegion = "US"
|
||||||
|
s.APIHost = "us1.api.wallarm.com"
|
||||||
|
}
|
||||||
|
} else if r.USReachable {
|
||||||
|
fmt.Println(" US (us1.api.wallarm.com) — only reachable region")
|
||||||
|
s.CloudRegion = "US"
|
||||||
|
s.APIHost = "us1.api.wallarm.com"
|
||||||
|
} else {
|
||||||
|
fmt.Println(" EU (api.wallarm.com) — only reachable region")
|
||||||
|
s.CloudRegion = "EU"
|
||||||
|
s.APIHost = "api.wallarm.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: API Token
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print("Wallarm API Token (Deploy role): ")
|
||||||
|
token, _ := reader.ReadString('\n')
|
||||||
|
s.APIToken = strings.TrimSpace(token)
|
||||||
|
if s.APIToken == "" {
|
||||||
|
fmt.Println("Token cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Node configuration
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print("Node name: ")
|
||||||
|
nodeName, _ := reader.ReadString('\n')
|
||||||
|
nodeName = strings.TrimSpace(nodeName)
|
||||||
|
if nodeName == "" {
|
||||||
|
fmt.Println("Node name required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Listen address [0.0.0.0:8081]: ")
|
||||||
|
address, _ := reader.ReadString('\n')
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if address == "" {
|
||||||
|
address = "0.0.0.0:8081"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Labels [group=" + nodeName + "]: ")
|
||||||
|
labels, _ := reader.ReadString('\n')
|
||||||
|
labels = strings.TrimSpace(labels)
|
||||||
|
if labels == "" {
|
||||||
|
labels = "group=" + nodeName
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Nodes = append(s.Nodes, state.Node{
|
||||||
|
Name: nodeName,
|
||||||
|
Type: "native",
|
||||||
|
Address: address,
|
||||||
|
Status: "deploying",
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 4: Deploy
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Starting deployment...")
|
||||||
|
|
||||||
|
if err := native.CreateNodesDir(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := native.GenerateSystemdTemplate(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range s.Nodes {
|
||||||
|
if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Deployment failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
if err := state.Save(&s); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: could not save state: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("✅ Deployment complete! Run 'wallarm' again for the dashboard.")
|
||||||
|
}
|
||||||
func runTunnelFlow() {
|
func runTunnelFlow() {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("🔗 Remote Assistance — Secure Tunnel Setup")
|
fmt.Println("🔗 Remote Assistance — Secure Tunnel Setup")
|
||||||
|
|
@ -155,13 +259,21 @@ deployment. On subsequent runs, it shows your existing deployments.
|
||||||
fmt.Println("✅ Preflight checks passed.")
|
fmt.Println("✅ Preflight checks passed.")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// 2. Launch bubbletea TUI (handles wizard vs dashboard routing)
|
// 2. Launch bubbletea TUI (deploy choice or dashboard)
|
||||||
err := ui.Run(result)
|
if err := ui.Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "UI error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "UI error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If TUI exited cleanly (user chose Remote Assistance), start tunnel flow
|
// 3. Post-TUI: if no deployment exists, run deploy form in plain terminal
|
||||||
runTunnelFlow()
|
if !state.HasDeployment() {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print("Start deployment now? [Y/n]: ")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
answer, _ := reader.ReadString('\n')
|
||||||
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
if answer == "" || answer == "y" || answer == "yes" {
|
||||||
|
runDeployFlow(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
// Package ui provides the bubbletea terminal UI for wallarm:
|
// Package ui provides the bubbletea terminal UI for wallarm:
|
||||||
// - Wizard: guides new deployments (type → region → config → deploy)
|
// - Deploy choice: local deploy or remote assistance
|
||||||
// - Dashboard: lists existing nodes with actions (add, config, remove, tunnel)
|
// - Dashboard: lists existing nodes with actions
|
||||||
|
//
|
||||||
|
// Deployment logic runs in plain terminal (main.go), not inside the TUI.
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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"
|
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Styles
|
|
||||||
var (
|
var (
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1)
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1)
|
||||||
goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||||
|
|
@ -27,37 +24,22 @@ var (
|
||||||
activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
|
activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model is the top-level bubbletea model.
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
state stateView
|
state stateView
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
status string // feedback message during deployment
|
|
||||||
deploying bool // true while deployment is in progress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type stateView int
|
type stateView int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
viewPreflight stateView = iota
|
viewRoute stateView = iota
|
||||||
viewRoute
|
|
||||||
viewDeployChoice // local deploy or remote assist?
|
viewDeployChoice // local deploy or remote assist?
|
||||||
viewWizard
|
|
||||||
viewDashboard
|
viewDashboard
|
||||||
viewDone
|
|
||||||
viewError
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var preflightResult preflight.Result
|
// Run starts the bubbletea TUI. Returns nil if user chose local deploy (exit and run form).
|
||||||
|
func Run() error {
|
||||||
// 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}
|
m := Model{state: viewRoute}
|
||||||
p := tea.NewProgram(m)
|
p := tea.NewProgram(m)
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
|
|
@ -76,32 +58,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "1":
|
case "1":
|
||||||
if m.state == viewDeployChoice {
|
if m.state == viewDeployChoice {
|
||||||
m.state = viewWizard
|
return m, tea.Quit // exit TUI, main.go runs deploy form
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
case "2":
|
case "2":
|
||||||
if m.state == viewDeployChoice {
|
if m.state == viewDeployChoice {
|
||||||
return m, func() tea.Msg { return tea.Quit() } // exit TUI for tunnel flow
|
return m, tea.Quit // exit TUI, main.go runs tunnel flow
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if m.state == viewWizard && !m.deploying {
|
|
||||||
m.deploying = true
|
|
||||||
return m, m.startDeploy()
|
|
||||||
}
|
|
||||||
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
|
@ -124,33 +87,12 @@ func (m Model) View() string {
|
||||||
return ""
|
return ""
|
||||||
case viewDeployChoice:
|
case viewDeployChoice:
|
||||||
return deployChoiceView(m)
|
return deployChoiceView(m)
|
||||||
case viewWizard:
|
|
||||||
return wizardView(m)
|
|
||||||
case viewDashboard:
|
case viewDashboard:
|
||||||
return dashboardView(m)
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) startDeploy() tea.Cmd {
|
|
||||||
m.deploying = true
|
|
||||||
return func() tea.Msg {
|
|
||||||
s, err := runDeployForm()
|
|
||||||
if err != nil {
|
|
||||||
return deployCompleteMsg{err}
|
|
||||||
}
|
|
||||||
err = deployNative(s)
|
|
||||||
if err == nil {
|
|
||||||
state.Save(&s)
|
|
||||||
}
|
|
||||||
return deployCompleteMsg{err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Deploy Choice ───────────────────────────────────────────────────
|
// ─── Deploy Choice ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func deployChoiceView(m Model) string {
|
func deployChoiceView(m Model) string {
|
||||||
|
|
@ -159,157 +101,11 @@ func deployChoiceView(m Model) string {
|
||||||
s += activeStyle.Render(" [1] Deploy locally") + "\n"
|
s += activeStyle.Render(" [1] Deploy locally") + "\n"
|
||||||
s += " Configure and deploy the Wallarm node on this server now.\n\n"
|
s += " Configure and deploy the Wallarm node on this server now.\n\n"
|
||||||
s += activeStyle.Render(" [2] Remote assistance") + "\n"
|
s += activeStyle.Render(" [2] Remote assistance") + "\n"
|
||||||
s += " Open a secure tunnel so a remote admin can deploy for you.\n"
|
s += " Open a secure tunnel so a remote admin can deploy for you.\n\n"
|
||||||
s += " No credentials are exposed — you provide the tunnel endpoint.\n\n"
|
|
||||||
s += dimStyle.Render("Press 1 or 2, q to quit")
|
s += dimStyle.Render("Press 1 or 2, q to quit")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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("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 Enter to continue, 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("[1] US (us1.api.wallarm.com)", "US"))
|
|
||||||
}
|
|
||||||
if preflightResult.EUReachable {
|
|
||||||
regionOptions = append(regionOptions, huh.NewOption("[2] 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(®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 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 ───────────────────────────────────────────────────────
|
// ─── Dashboard ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
func dashboardView(m Model) string {
|
func dashboardView(m Model) string {
|
||||||
|
|
@ -333,13 +129,9 @@ func dashboardView(m Model) string {
|
||||||
marker = "○"
|
marker = "○"
|
||||||
style = warnStyle
|
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))
|
out += style.Render(fmt.Sprintf(" %s %s — %s", marker, n.Name, n.Status))
|
||||||
if detail != "" {
|
if n.Address != "" {
|
||||||
out += dimStyle.Render(fmt.Sprintf(" (%s)", detail))
|
out += dimStyle.Render(fmt.Sprintf(" (%s)", n.Address))
|
||||||
}
|
}
|
||||||
out += "\n"
|
out += "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -355,4 +147,3 @@ func dashboardView(m Model) string {
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue