Skip to content
119 changes: 119 additions & 0 deletions internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
package bootstrap

import (
"bufio"
"flag"
"fmt"
"os"
"sort"
"strconv"
"strings"

"github.com/devnullvoid/pvetui/internal/app"
Expand Down Expand Up @@ -236,6 +238,37 @@ func Bootstrap(opts BootstrapOptions) (*BootstrapResult, error) {
return nil, fmt.Errorf("profile resolution failed: %w", err)
}

// If a profile/group name was resolved but no longer exists (e.g. stale
// default_profile after a rename or delete), warn and fall back to interactive
// selection rather than hard-erroring.
if selectedProfile != "" && len(cfg.Profiles) > 0 {
_, isProfile := cfg.Profiles[selectedProfile]
isGroup := cfg.IsGroup(selectedProfile)
if !isProfile && !isGroup {
fmt.Printf("⚠️ Profile/group '%s' not found — falling back to selection.\n", selectedProfile)
selectedProfile = ""
}
}

// When no profile is resolved but profiles are configured, either auto-select
// (single profile, no groups) or prompt interactively.
if selectedProfile == "" && len(cfg.Profiles) > 0 {
groups := cfg.GetGroups()
if len(cfg.Profiles) == 1 && len(groups) == 0 {
// Only one profile available — auto-select silently.
for name := range cfg.Profiles {
selectedProfile = name
}
} else {
chosen, err := promptProfileSelection(cfg)
if err != nil {
fmt.Println("🚪 Exiting.")
os.Exit(0)
}
selectedProfile = chosen
}
}

// Determine if selected profile is an aggregate group or a standard profile
var initialGroup string
var startupProfile string
Expand Down Expand Up @@ -589,3 +622,89 @@ func valueOrDash(value string) string {
}
return value
}

// promptProfileSelection interactively asks the user which profile or group to
// connect to when no default is configured.
//
// It returns the selected profile or group name, or an error if the user
// cancels or no valid input is provided.
func promptProfileSelection(cfg *config.Config) (string, error) {
// Collect groups (sorted)
groups := cfg.GetGroups()
groupNames := make([]string, 0, len(groups))
for name := range groups {
groupNames = append(groupNames, name)
}
sort.Strings(groupNames)

// Collect individual profile names (sorted)
profileNames := make([]string, 0, len(cfg.Profiles))
for name := range cfg.Profiles {
profileNames = append(profileNames, name)
}
sort.Strings(profileNames)

total := len(groupNames) + len(profileNames)

// Build an ordered list: groups first, then profiles
type entry struct {
name string
isGroup bool
}
entries := make([]entry, 0, total)
for _, g := range groupNames {
entries = append(entries, entry{name: g, isGroup: true})
}
for _, p := range profileNames {
entries = append(entries, entry{name: p, isGroup: false})
}

fmt.Println()
fmt.Println("No default profile configured.")
fmt.Println()
fmt.Println("Available connections:")
fmt.Println()

idx := 1
if len(groupNames) > 0 {
fmt.Println(" Groups:")
for _, g := range groupNames {
modeTag := ""
if cfg.IsClusterGroup(g) {
modeTag = " [cluster]"
}
fmt.Printf(" %d. %s (%d profiles)%s\n", idx, g, len(groups[g]), modeTag)
idx++
}
fmt.Println()
}

if len(profileNames) > 0 {
fmt.Println(" Profiles:")
for _, p := range profileNames {
addr := cfg.Profiles[p].Addr
user := cfg.Profiles[p].User
fmt.Printf(" %d. %s - %s (%s)\n", idx, p, addr, user)
idx++
}
fmt.Println()
}

scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Printf("Select connection [1-%d]: ", total)
if !scanner.Scan() {
// EOF or Ctrl+C
fmt.Println()
return "", fmt.Errorf("selection canceled")
}
line := strings.TrimSpace(scanner.Text())
n, err := strconv.Atoi(line)
if err != nil || n < 1 || n > total {
fmt.Printf("Invalid selection. Please enter a number between 1 and %d.\n", total)
continue
}
selected := entries[n-1]
return selected.name, nil
}
}
7 changes: 1 addition & 6 deletions internal/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ func ResolveProfile(flagProfile string, cfg *config.Config) (string, error) {
return cfg.DefaultProfile, nil
}

// If no explicit profile but profiles exist, use "default"
if len(cfg.Profiles) > 0 {
return "default", nil
}

// No profile selected (no profiles configured)
// No default set - return empty string to signal interactive selection is needed
return "", nil
}

Expand Down
87 changes: 79 additions & 8 deletions internal/ui/components/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package components
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"

"github.com/gdamore/tcell/v2"
Expand All @@ -23,6 +25,11 @@ import (
type App struct {
*tview.Application

// connMu protects the connection-state fields below from concurrent access.
// Background goroutines (doManualRefresh, doFastRefresh, autoRefreshData, loadTasksData)
// read these fields; profile-switch goroutines (applyConnectionProfile, switchToClusterGroup)
// write them. Always use snapConn() for reads and connMu.Lock() for writes.
connMu sync.RWMutex
client *api.Client
groupManager *api.GroupClientManager
isGroupMode bool
Expand Down Expand Up @@ -58,11 +65,18 @@ type App struct {

// Auto-refresh functionality
autoRefreshEnabled bool
autoRefreshTicker *time.Ticker
autoRefreshStop chan bool
autoRefreshRunning bool
autoRefreshCountdown int
autoRefreshCountdownStop chan bool

// Refresh deduplication: prevents concurrent overlapping refreshes.
// refreshGen holds the current refresh generation token (0 = no refresh in progress,
// nonzero = a refresh is in progress with that token). Each refresh operation acquires
// a unique token via startRefresh or forceNewRefresh, and releases it via endRefresh.
// Old callbacks that call endRefresh with a stale token are no-ops, preventing the
// guard from being cleared by a refresh that was superseded by a profile switch.
refreshGen atomic.Uint64

plugins map[string]Plugin
pluginRegistry *pluginRegistry
pluginCatalog []PluginInfo
Expand All @@ -79,6 +93,61 @@ func (a *App) removePageIfPresent(name string) {
}
}

// startRefresh attempts to acquire the refresh guard.
// Returns (token, true) on success; (0, false) if another refresh is already in progress.
// The caller must pass the returned token to endRefresh when the refresh completes.
func (a *App) startRefresh() (uint64, bool) {
token := uint64(time.Now().UnixNano()) | 1 // ensure nonzero
if a.refreshGen.CompareAndSwap(0, token) {
return token, true
}
return 0, false
}

// forceNewRefresh unconditionally acquires the refresh guard with a new token,
// discarding any in-flight refresh. Old callbacks whose endRefresh calls will be
// no-ops because their token won't match the current value.
// Used by profile switches where we need to guarantee a new refresh starts.
// The caller must pass the returned token to endRefresh (or doFastRefresh/doManualRefresh).
func (a *App) forceNewRefresh() uint64 {
token := uint64(time.Now().UnixNano()) | 1 // ensure nonzero
a.refreshGen.Store(token)
return token
}

// endRefresh releases the refresh guard only if the token matches the current value.
// If the token doesn't match (stale callback from a superseded refresh), this is a no-op.
func (a *App) endRefresh(token uint64) {
a.refreshGen.CompareAndSwap(token, 0)
}

// isRefreshActive returns true if a refresh is currently in progress.
func (a *App) isRefreshActive() bool {
return a.refreshGen.Load() != 0
}

// connData is a snapshot of connection-state fields captured under connMu.
type connData struct {
isGroupMode bool
isClusterMode bool
client *api.Client
groupManager *api.GroupClientManager
}

// snapConn returns a consistent, lock-safe snapshot of connection state.
// Callers must use the returned values for subsequent operations instead of
// reading a.client / a.isGroupMode / etc. directly from background goroutines.
func (a *App) snapConn() connData {
a.connMu.RLock()
defer a.connMu.RUnlock()
return connData{
isGroupMode: a.isGroupMode,
isClusterMode: a.isClusterMode,
client: a.client,
groupManager: a.groupManager,
}
}

// NewApp creates a new application instance with all UI components.
func NewApp(ctx context.Context, client *api.Client, cfg *config.Config, configPath string, initialGroup string) *App {
uiLogger := models.GetUILogger()
Expand Down Expand Up @@ -549,12 +618,13 @@ func (a *App) ClearAPICache() {
// In group mode, it returns the client for the VM's source profile.
// In single-profile mode, it returns the main client.
func (a *App) getClientForVM(vm *api.VM) (*api.Client, error) {
if a.isGroupMode {
conn := a.snapConn()
if conn.isGroupMode {
if vm.SourceProfile == "" {
return nil, fmt.Errorf("source profile not set for VM %s in group mode", vm.Name)
}

profileClient, exists := a.groupManager.GetClient(vm.SourceProfile)
profileClient, exists := conn.groupManager.GetClient(vm.SourceProfile)
if !exists {
return nil, fmt.Errorf("profile '%s' not found in group manager", vm.SourceProfile)
}
Expand All @@ -566,19 +636,20 @@ func (a *App) getClientForVM(vm *api.VM) (*api.Client, error) {

return profileClient.Client, nil
}
return a.client, nil
return conn.client, nil
}

// getClientForNode returns the appropriate API client for a Node.
// In group mode, it returns the client for the Node's source profile.
// In single-profile mode, it returns the main client.
func (a *App) getClientForNode(node *api.Node) (*api.Client, error) {
if a.isGroupMode {
conn := a.snapConn()
if conn.isGroupMode {
if node.SourceProfile == "" {
return nil, fmt.Errorf("source profile not set for Node %s in group mode", node.Name)
}

profileClient, exists := a.groupManager.GetClient(node.SourceProfile)
profileClient, exists := conn.groupManager.GetClient(node.SourceProfile)
if !exists {
return nil, fmt.Errorf("profile '%s' not found in group manager", node.SourceProfile)
}
Expand All @@ -590,7 +661,7 @@ func (a *App) getClientForNode(node *api.Node) (*api.Client, error) {

return profileClient.Client, nil
}
return a.client, nil
return conn.client, nil
}

// createSyntheticGroup creates a synthetic cluster object from a list of nodes for group display.
Expand Down
Loading
Loading