Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Cluster Group Mode (HA Failover)**: Added per-group `group_settings` with `mode: cluster` so a group can connect through one active profile and automatically fail over to the next healthy candidate.
- **Group Mode Controls in Profiles UI**: Added a mode selector to the Edit Group dialog and a cluster mode marker in the Connection Profiles list.

### Fixed

- **Tasks Panel Focus Freeze**: Fixed an immediate freeze when tabbing from Task History to Active Operations by keeping task table cells selectable and avoiding a tview table selection loop.
- **Cluster Failover Refresh Re-entrancy**: Triggered failover refresh asynchronously to avoid nested `QueueUpdateDraw` callback paths.

## [1.0.19] - 2026-02-21

### Added
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ type Config struct {
Theme ThemeConfig `yaml:"theme"`
Plugins PluginConfig `yaml:"plugins"`
ShowIcons bool `yaml:"show_icons"`
GroupSettings map[string]GroupSettingsConfig `yaml:"group_settings,omitempty"`
// Deprecated: legacy single-profile fields for migration
Addr string `yaml:"addr"`
User string `yaml:"user"`
Expand Down Expand Up @@ -405,6 +406,7 @@ func (c *Config) MergeWithFile(path string) error {
Enabled []string `yaml:"enabled"`
} `yaml:"plugins"`
ShowIcons *bool `yaml:"show_icons"`
GroupSettings map[string]GroupSettingsConfig `yaml:"group_settings"`
// Legacy fields for migration
Addr string `yaml:"addr"`
User string `yaml:"user"`
Expand Down Expand Up @@ -667,6 +669,14 @@ func (c *Config) MergeWithFile(path string) error {
c.Theme.Colors[k] = v
}


// Merge group_settings configuration if provided
if fileConfig.GroupSettings != nil {
c.GroupSettings = make(map[string]GroupSettingsConfig, len(fileConfig.GroupSettings))
for k, v := range fileConfig.GroupSettings {
c.GroupSettings[k] = v
}
}
// Decrypt sensitive fields if not using SOPS
// SOPS handles encryption/decryption itself, so we only decrypt age-encrypted fields
if !IsSOPSEncrypted(path, data) {
Expand Down
2 changes: 2 additions & 0 deletions internal/config/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type marshaledConfig struct {
KeyBindings KeyBindings `yaml:"key_bindings,omitempty"`
Theme ThemeConfig `yaml:"theme,omitempty"`
Plugins PluginConfig `yaml:"plugins"`
GroupSettings map[string]GroupSettingsConfig `yaml:"group_settings,omitempty"`
Addr string `yaml:"addr,omitempty"`
User string `yaml:"user,omitempty"`
Password string `yaml:"password,omitempty"`
Expand Down Expand Up @@ -39,6 +40,7 @@ func (cfg *Config) MarshalYAML() (any, error) {
KeyBindings: cfg.KeyBindings,
Theme: cfg.Theme,
Plugins: cfg.Plugins,
GroupSettings: cfg.GroupSettings,
}

if len(cfg.Profiles) == 0 {
Expand Down
70 changes: 69 additions & 1 deletion internal/config/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ type ProfileConfig struct {
Groups []string `yaml:"groups,omitempty"`
}


// GroupMode constants define the operational mode for a group.
const (
// GroupModeAggregate is the default mode: all profiles connect simultaneously
// and their data is merged into a unified view (multi-cluster).
GroupModeAggregate = "aggregate"

// GroupModeCluster connects to a single profile at a time with automatic
// failover to the next profile when the active one becomes unreachable.
// This is intended for multiple nodes of the same Proxmox cluster.
GroupModeCluster = "cluster"
)

// GroupSettingsConfig holds per-group configuration options.
type GroupSettingsConfig struct {
// Mode determines the operational mode for the group.
// "aggregate" (default): connect to all profiles, merge data.
// "cluster": connect to one profile at a time with HA failover.
Mode string `yaml:"mode"`
}

// ApplyProfile applies the settings from a named profile to the main config.
func (c *Config) ApplyProfile(profileName string) error {
if c.Profiles == nil {
Expand Down Expand Up @@ -259,16 +280,33 @@ func (c *Config) HasGroups() bool {
}

// ValidateGroups checks that group configurations are valid.
// This validates naming conflicts and group_settings entries.
func (c *Config) ValidateGroups() error {
groups := c.GetGroups()

for groupName := range groups {
// Check for naming conflicts between profiles and groups
if _, exists := c.Profiles[groupName]; exists {
return fmt.Errorf("group name '%s' conflicts with profile name", groupName)
}
}

// Validate group_settings entries
for name, settings := range c.GroupSettings {
// Group settings must reference an actual group
if _, exists := groups[name]; !exists {
return fmt.Errorf("group_settings '%s' does not match any group", name)
}

// Validate mode value
switch settings.Mode {
case GroupModeAggregate, GroupModeCluster, "":
// valid
default:
return fmt.Errorf("group_settings '%s' has invalid mode '%s' (must be '%s' or '%s')",
name, settings.Mode, GroupModeAggregate, GroupModeCluster)
}

}
return nil
}

Expand All @@ -286,3 +324,33 @@ func (c *Config) FindGroupProfileNameConflicts() []string {
sort.Strings(conflicts)
return conflicts
}

// GetGroupMode returns the operational mode for a group.
// Returns GroupModeCluster if configured, otherwise GroupModeAggregate (default).
func (c *Config) GetGroupMode(groupName string) string {
if settings, exists := c.GroupSettings[groupName]; exists {
if settings.Mode == GroupModeCluster {
return GroupModeCluster
}
}
return GroupModeAggregate
}

// IsClusterGroup returns true if the named group is configured in cluster (HA failover) mode.
func (c *Config) IsClusterGroup(groupName string) bool {
return c.GetGroupMode(groupName) == GroupModeCluster
}

// SetGroupMode sets the operational mode for a group.
// Creates the GroupSettings map if needed.
func (c *Config) SetGroupMode(groupName string, mode string) {
if c.GroupSettings == nil {
c.GroupSettings = make(map[string]GroupSettingsConfig)
}
if mode == GroupModeAggregate || mode == "" {
// Aggregate is the default — remove the entry to keep config clean
delete(c.GroupSettings, groupName)
return
}
c.GroupSettings[groupName] = GroupSettingsConfig{Mode: mode}
}
30 changes: 30 additions & 0 deletions internal/config/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,33 @@ func TestGetProfilesInGroup(t *testing.T) {
pNames3 := cfg.GetProfileNamesInGroup("group3")
assert.Empty(t, pNames3)
}

func TestValidateGroupsRejectsStaleGroupSettings(t *testing.T) {
cfg := &Config{
Profiles: map[string]ProfileConfig{
"p1": {Groups: []string{"group1"}},
},
GroupSettings: map[string]GroupSettingsConfig{
"deleted-group": {Mode: GroupModeCluster},
},
}

err := cfg.ValidateGroups()
assert.Error(t, err)
assert.Contains(t, err.Error(), "group_settings 'deleted-group' does not match any group")
}

func TestValidateGroupsAcceptsClusterModeSettingForExistingGroup(t *testing.T) {
cfg := &Config{
Profiles: map[string]ProfileConfig{
"p1": {Groups: []string{"group1"}},
},
GroupSettings: map[string]GroupSettingsConfig{
"group1": {Mode: GroupModeCluster},
},
}

err := cfg.ValidateGroups()
assert.NoError(t, err)
assert.True(t, cfg.IsClusterGroup("group1"))
}
12 changes: 12 additions & 0 deletions internal/ui/components/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type App struct {
groupManager *api.GroupClientManager
isGroupMode bool
groupName string
clusterClient *api.ClusterClient
isClusterMode bool
config config.Config
configPath string
vncService *vnc.Service
Expand Down Expand Up @@ -477,6 +479,16 @@ func (a *App) GroupManager() *api.GroupClientManager {
return a.groupManager
}

// IsClusterMode returns whether the app is running in cluster (HA failover) mode.
func (a *App) IsClusterMode() bool {
return a.isClusterMode
}

// ClusterClient returns the cluster client if in cluster mode, nil otherwise.
func (a *App) ClusterClient() *api.ClusterClient {
return a.clusterClient
}

// Header returns the header component instance.
func (a *App) Header() HeaderComponent {
return a.header
Expand Down
13 changes: 10 additions & 3 deletions internal/ui/components/app_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ func (a *App) Run() error {

defer func() {
a.stopAutoRefresh()
// Stop cluster health checks on exit
if a.clusterClient != nil {
a.clusterClient.Close()
}
a.cancel()
}()

Expand All @@ -37,12 +41,15 @@ func (a *App) Run() error {

// updateHeaderWithActiveProfile updates the header to show the current active profile or group.
func (a *App) updateHeaderWithActiveProfile() {
if a.isGroupMode {
// In group mode, show "Group: <name>"
if a.isClusterMode && a.clusterClient != nil {
// In cluster mode, show "Cluster: <name> (via <activeProfile>)"
activeProfile := a.clusterClient.GetActiveProfileName()
a.header.ShowActiveProfile(fmt.Sprintf("Cluster: %s (via %s)", a.groupName, activeProfile))
} else if a.isGroupMode {
// In aggregate group mode, show "Group: <name>"
a.header.ShowActiveProfile(fmt.Sprintf("Group: %s", a.groupName))
} else {
profileName := a.config.GetActiveProfile()

if profileName == "" {
a.header.ShowActiveProfile("")
} else {
Expand Down
38 changes: 32 additions & 6 deletions internal/ui/components/connection_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,18 @@ func (a *App) showConnectionProfilesDialog() {
for _, groupName := range groupNames {
profileNames := groups[groupName]

displayName := fmt.Sprintf("▸ %s (%d profiles)", groupName, len(profileNames))

// Check if currently connected to this group
// Determine group mode indicator
var modeTag string
if a.config.IsClusterGroup(groupName) {
modeTag = " [accent]cluster[-]"
}

if a.isGroupMode && a.groupName == groupName {
displayName := fmt.Sprintf("▸ %s (%d profiles)%s", groupName, len(profileNames), modeTag)

// Check if currently connected to this group (aggregate or cluster mode)
if (a.isGroupMode || a.isClusterMode) && a.groupName == groupName {
displayName = "⚡ " + displayName

}

if groupName == a.config.DefaultProfile {
displayName = displayName + " ⭐"
}
Expand Down Expand Up @@ -574,6 +576,14 @@ func (a *App) showDeleteGroupDialog(groupName string) {
hasChanges = true
}

// Remove per-group settings to avoid stale group_settings entries.
if a.config.GroupSettings != nil {
if _, exists := a.config.GroupSettings[groupName]; exists {
delete(a.config.GroupSettings, groupName)
hasChanges = true
}
}

if hasChanges {

// Save the config
Expand Down Expand Up @@ -979,6 +989,15 @@ func (a *App) showEditGroupDialog(groupName string) {

form.SetBorderColor(theme.Colors.Border)

// Mode selector (aggregate vs cluster)
modeOptions := []string{config.GroupModeAggregate, config.GroupModeCluster}
currentMode := a.config.GetGroupMode(groupName)
currentModeIndex := 0
if currentMode == config.GroupModeCluster {
currentModeIndex = 1
}
form.AddDropDown("Mode", modeOptions, currentModeIndex, nil)

// Collect and sort profile names

profileNames := make([]string, 0)
Expand Down Expand Up @@ -1039,6 +1058,13 @@ func (a *App) showEditGroupDialog(groupName string) {

hasChanges := false

// Persist mode selection
_, selectedMode := form.GetFormItemByLabel("Mode").(*tview.DropDown).GetCurrentOption()
if selectedMode != a.config.GetGroupMode(groupName) {
a.config.SetGroupMode(groupName, selectedMode)
hasChanges = true
}

for name, checked := range selections {

profile := a.config.Profiles[name]
Expand Down
Loading