From abd1ac5b50cfa1c68a4f3adaba245e1ff9e68ad7 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Fri, 24 Apr 2026 08:16:14 +0000 Subject: [PATCH 01/55] feat(browser): fixed name of navitem and fixed color background Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index db56293d..1d44f1dd 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -292,7 +292,6 @@ var ( // Navigation bar navBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#1a1a1a")). Padding(0, 1) navItemStyle = lipgloss.NewStyle(). @@ -534,10 +533,10 @@ type kubeNodePoolsLoadedMsg struct { func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, - {Label: "Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, - {Label: "Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, + {Label: " Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, + {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, - {Label: "Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/s3"}, + {Label: "Block Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/s3"}, {Label: "Private networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, } } From 814d2357355f13310b36ab4e5fe93da8954ea766 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Fri, 24 Apr 2026 13:43:30 +0000 Subject: [PATCH 02/55] feat(browser): added block_storage you can create an block_storage delete/edit/change size Signed-off-by: olivier dubo --- internal/services/browser/api.go | 346 ++++++++++- internal/services/browser/manager.go | 557 +++++++++++++++++- .../browser/views/block_storage/detail.go | 294 +++++++++ .../browser/views/block_storage/table.go | 213 +++++++ 4 files changed, 1400 insertions(+), 10 deletions(-) create mode 100644 internal/services/browser/views/block_storage/detail.go create mode 100644 internal/services/browser/views/block_storage/table.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 93885ea5..f5edaf2b 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -9,12 +9,13 @@ package browser import ( "encoding/json" "fmt" - "net/url" "math/rand" + "net/url" "os" "os/exec" "path/filepath" "sort" + "strconv" "strings" "time" @@ -24,6 +25,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/ovh/ovhcloud-cli/internal/assets" httpLib "github.com/ovh/ovhcloud-cli/internal/http" + block_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/block_storage" ) // fetchDataForPath initiates an API call based on the path @@ -821,6 +823,280 @@ func (m Model) fetchBlockStorageData() dataLoadedMsg { } } +// fetchVolumeRegions probes each region concurrently for volume type support and returns +// only regions that have at least one volume type available. This filters out legacy/local +// regions that don't support the block storage volume API. +func (m Model) fetchVolumeRegions() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeRegionsLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + return volumeRegionsLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + type probeResult struct { + region string + types []string + } + ch := make(chan probeResult, len(regionNames)) + for _, name := range regionNames { + go func(regionName string) { + typesEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeType", + m.cloudProject, url.PathEscape(regionName)) + var rawTypes []map[string]interface{} + if err := httpLib.Client.Get(typesEndpoint, &rawTypes); err != nil { + ch <- probeResult{region: regionName, types: nil} + return + } + var types []string + for _, t := range rawTypes { + if n, ok := t["name"].(string); ok && n != "" { + types = append(types, n) + } + } + sort.Strings(types) + ch <- probeResult{region: regionName, types: types} + }(name) + } + + regionTypeMap := make(map[string][]string) + for range regionNames { + r := <-ch + if len(r.types) > 0 { + regionTypeMap[r.region] = r.types + } + } + var supported []string + for _, name := range regionNames { + if _, ok := regionTypeMap[name]; ok { + supported = append(supported, name) + } + } + sort.Strings(supported) + + if len(supported) == 0 { + return volumeRegionsLoadedMsg{err: fmt.Errorf("no regions support block storage volumes in this project")} + } + return volumeRegionsLoadedMsg{regionNames: supported, regionTypeMap: regionTypeMap} + } +} + +func (m Model) fetchVolumeTypes(region string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeTypesLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeType", m.cloudProject, url.PathEscape(region)) + var rawTypes []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &rawTypes); err != nil { + return volumeTypesLoadedMsg{err: fmt.Errorf("failed to fetch volume types: %w", err)} + } + var types []string + for _, t := range rawTypes { + if n, ok := t["name"].(string); ok && n != "" { + types = append(types, n) + } + } + sort.Strings(types) + return volumeTypesLoadedMsg{types: types} + } +} + +func (m Model) fetchVolumeAvailabilityZones(region string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeAZLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s", m.cloudProject, url.PathEscape(region)) + var regionDetail map[string]interface{} + if err := httpLib.Client.Get(endpoint, ®ionDetail); err != nil { + return volumeAZLoadedMsg{err: fmt.Errorf("failed to fetch region details: %w", err)} + } + var azs []string + if raw, ok := regionDetail["availabilityZones"].([]interface{}); ok { + for _, az := range raw { + if s, ok := az.(string); ok { + azs = append(azs, s) + } + } + } + sort.Strings(azs) + return volumeAZLoadedMsg{availabilityZones: azs, err: nil} + } +} + +func (m Model) createVolume() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + body := map[string]interface{}{ + "name": m.wizard.volumeName, + "size": m.wizard.volumeSize, + "type": m.wizard.volumeType, + } + if m.wizard.volumeAvailabilityZone != "" { + body["availabilityZone"] = m.wizard.volumeAvailabilityZone + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volume", m.cloudProject, url.PathEscape(m.wizard.selectedRegion)) + var volume map[string]interface{} + err := httpLib.Client.Post(endpoint, body, &volume) + return volumeCreatedMsg{volume: volume, err: err} + } +} + +func (m Model) deleteVolume(volumeId string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(volumeId)) + err := httpLib.Client.Delete(endpoint, nil) + return volumeActionDoneMsg{action: 0, err: err} + } +} + +func (m Model) renameVolume(volumeId, newName string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(volumeId)) + body := map[string]interface{}{"name": newName} + err := httpLib.Client.Put(endpoint, body, nil) + return volumeActionDoneMsg{action: 1, err: err} + } +} + +func (m Model) extendVolume(volumeId string, newSizeGB int) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s/upsize", m.cloudProject, url.PathEscape(volumeId)) + body := map[string]interface{}{"size": newSizeGB} + err := httpLib.Client.Post(endpoint, body, nil) + return volumeActionDoneMsg{action: 2, err: err} + } +} + +// handleVolumeRegionsLoaded handles the response from the concurrent region probe. +// It populates the region list (only volume-capable regions) and stores the type map. +func (m Model) handleVolumeRegionsLoaded(msg volumeRegionsLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.regions = nil + for _, name := range msg.regionNames { + m.wizard.regions = append(m.wizard.regions, map[string]interface{}{"name": name}) + } + m.wizard.volumeRegionTypeMap = msg.regionTypeMap + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleVolumeTypesLoaded(msg volumeTypesLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.volumeTypes = msg.types + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleVolumeAZLoaded(msg volumeAZLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.volumeAvailabilityZones = msg.availabilityZones + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleVolumeCreated(msg volumeCreatedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + name := "" + if msg.volume != nil { + if n, ok := msg.volume["name"].(string); ok { + name = n + } + } + if name == "" { + name = "volume" + } + m.notification = fmt.Sprintf("✅ Volume '%s' created successfully!", name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.wizard = WizardData{} + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/block"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + +func (m Model) handleExecuteVolumeAction(msg block_storage.ExecuteVolumeActionMsg) (tea.Model, tea.Cmd) { + volumeId := getString(msg.Volume, "id") + volumeName := getString(msg.Volume, "name") + + switch msg.Action { + case block_storage.VolumeActionDelete: + m.notification = fmt.Sprintf("🗑️ Deleting volume '%s'...", volumeName) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.deleteVolume(volumeId) + case block_storage.VolumeActionRename: + m.notification = fmt.Sprintf("✏️ Renaming volume...") + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.renameVolume(volumeId, msg.Param) + case block_storage.VolumeActionExtend: + newSize, err := strconv.Atoi(msg.Param) + if err != nil || newSize < 1 { + m.notification = "❌ Invalid size" + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, nil + } + m.notification = fmt.Sprintf("⬆️ Extending volume to %d GB...", newSize) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.extendVolume(volumeId, newSize) + } + return m, nil +} + +func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Action failed: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + actionNames := []string{"deleted", "renamed", "extended"} + actionName := "updated" + if msg.action >= 0 && msg.action < len(actionNames) { + actionName = actionNames[msg.action] + } + m.notification = fmt.Sprintf("✅ Volume %s successfully!", actionName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.volumeDetailView = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/block"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + // fetchPrivateNetworksData fetches private networks across all regions func (m Model) fetchPrivateNetworksData() dataLoadedMsg { if m.cloudProject == "" { @@ -1104,6 +1380,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createKubernetesTable(msg.data, m.width, m.height) case ProductInstances: m.table = createInstancesTable(msg.data, m.imageMap, m.floatingIPMap, m.width, m.height) + case ProductStorage: + m.table = createBlockStorageTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -1463,6 +1741,72 @@ func createGenericTable(data []map[string]interface{}, width, height int) table. return t } +// createBlockStorageTable creates a nicely formatted table for block storage volumes. +func createBlockStorageTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 24}, + {Title: "ID", Width: 36}, + {Title: "Localisation", Width: 14}, + {Title: "Type", Width: 14}, + {Title: "Capacité", Width: 10}, + {Title: "Instance", Width: 20}, + {Title: "Statut", Width: 12}, + } + + var rows []table.Row + for _, vol := range data { + name := getString(vol, "name") + id := getString(vol, "id") + region := getString(vol, "region") + vType := getString(vol, "type") + size := "" + if s, ok := vol["size"]; ok { + size = fmt.Sprintf("%v GB", s) + } + instance := "-" + if raw, ok := vol["attachedTo"].([]interface{}); ok && len(raw) > 0 { + if id, ok := raw[0].(string); ok { + if len(id) > 18 { + instance = id[:18] + "…" + } else { + instance = id + } + } + } + status := getString(vol, "status") + rows = append(rows, table.Row{name, id, region, vType, size, instance, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + // getString safely extracts a string value from a map func getString(m map[string]interface{}, key string) string { if val, ok := m[key]; ok { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 1d44f1dd..a670a817 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "time" @@ -23,6 +24,8 @@ import ( "github.com/ovh/ovhcloud-cli/internal/config" "github.com/ovh/ovhcloud-cli/internal/flags" httpLib "github.com/ovh/ovhcloud-cli/internal/http" + block_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/block_storage" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" "github.com/spf13/cobra" ) @@ -87,6 +90,12 @@ const ( NodePoolWizardStepSize NodePoolWizardStepOptions NodePoolWizardStepConfirm + // Volume (Block Storage) wizard steps (offset by 300) + VolumeWizardStepRegion WizardStep = iota + 300 + VolumeWizardStepType + VolumeWizardStepAvailabilityZone + VolumeWizardStepConfig + VolumeWizardStepConfirm ) // ProductType represents a product category @@ -230,6 +239,18 @@ type WizardData struct { kubeKubeconfigCurrentDir string // Currently browsed directory kubeKubeconfigEntries []string // Subdirectory names in current dir kubeKubeconfigSelectedIdx int // 0="..", 1="[Save here]", 2+= entries + // Volume (Block Storage) wizard fields + volumeTypes []string // Available volume types for the selected region + volumeRegionTypeMap map[string][]string // region name -> []type names (pre-loaded) + volumeAvailabilityZones []string // Available availability zones for the region + volumeName string // Volume name input + volumeNameInput string // Input buffer for volume name + volumeSize int // Volume size in GB + volumeSizeInput string // Input buffer for volume size + volumeType string // Selected volume type + volumeAvailabilityZone string // Selected availability zone + volumeConfigFieldIdx int // 0 = name, 1 = size + volumeConfirmBtnIdx int // 0 = Create, 1 = Cancel } // Model represents the TUI application state @@ -273,6 +294,8 @@ type Model struct { // Background detail-view refresh (set by auto-refresh timer, cleared by data handlers) detailRefreshId string detailRefreshName string + // Block Storage detail view + volumeDetailView *block_storage.DetailView } // Navigation items for the top bar @@ -529,6 +552,32 @@ type kubeNodePoolsLoadedMsg struct { err error } +type volumeRegionsLoadedMsg struct { + regionNames []string + regionTypeMap map[string][]string + err error +} + +type volumeTypesLoadedMsg struct { + types []string + err error +} + +type volumeAZLoadedMsg struct { + availabilityZones []string + err error +} + +type volumeCreatedMsg struct { + volume map[string]interface{} + err error +} + +type volumeActionDoneMsg struct { + action int + err error +} + // Navigation items for products (shown after project is selected) func getNavItems() []NavItem { return []NavItem{ @@ -536,7 +585,7 @@ func getNavItems() []NavItem { {Label: " Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, - {Label: "Block Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/s3"}, + {Label: "Block Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, {Label: "Private networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, } } @@ -664,6 +713,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Loading Kubernetes regions...", } return m, m.fetchKubeRegions() + } else if msg.product == ProductStorage { + m.mode = WizardView + m.wizard = WizardData{ + step: VolumeWizardStepRegion, + isLoading: true, + loadingMessage: "Loading regions...", + } + return m, m.fetchVolumeRegions() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -806,6 +863,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case nodePoolDeletedMsg: return m.handleNodePoolDeleted(msg) + case volumeRegionsLoadedMsg: + return m.handleVolumeRegionsLoaded(msg) + + case volumeTypesLoadedMsg: + return m.handleVolumeTypesLoaded(msg) + + case volumeAZLoadedMsg: + return m.handleVolumeAZLoaded(msg) + + case volumeCreatedMsg: + return m.handleVolumeCreated(msg) + + case block_storage.ExecuteVolumeActionMsg: + return m.handleExecuteVolumeAction(msg) + + case views.GoBackMsg: + if m.mode == DetailView && m.currentProduct == ProductStorage { + m.volumeDetailView = nil + m.mode = TableView + return m, nil + } + return m, nil + + case volumeActionDoneMsg: + return m.handleVolumeActionDone(msg) + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -962,7 +1045,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 200 { + if m.wizard.step >= 300 { + // Volume wizard + titleText = " 💾 Create Volume " + } else if m.wizard.step >= 200 { // Node pool wizard titleText = " 🔧 Add Node Pool " } else if m.wizard.step >= 100 { @@ -1867,7 +1953,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 200 { + if m.wizard.step >= 300 { + // Volume wizard + steps = append(steps, "Region", "Type", "Avail. Zone", "Config", "Confirm") + stepMapping = append(stepMapping, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepConfig, VolumeWizardStepConfirm) + } else if m.wizard.step >= 200 { // Node pool wizard steps = append(steps, "Flavor", "Name", "Size", "Options", "Confirm") stepMapping = append(stepMapping, NodePoolWizardStepFlavor, NodePoolWizardStepName, NodePoolWizardStepSize, NodePoolWizardStepOptions, NodePoolWizardStepConfirm) @@ -1977,6 +2067,17 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderNodePoolWizardOptionsStep(width)) case NodePoolWizardStepConfirm: content.WriteString(m.renderNodePoolWizardConfirmStep(width)) + // Volume wizard steps + case VolumeWizardStepRegion: + content.WriteString(m.renderVolumeWizardRegionStep(width)) + case VolumeWizardStepType: + content.WriteString(m.renderVolumeWizardTypeStep(width)) + case VolumeWizardStepAvailabilityZone: + content.WriteString(m.renderVolumeWizardAZStep(width)) + case VolumeWizardStepConfig: + content.WriteString(m.renderVolumeWizardConfigStep(width)) + case VolumeWizardStepConfirm: + content.WriteString(m.renderVolumeWizardConfirmStep(width)) } return content.String() @@ -3209,7 +3310,186 @@ func (m Model) renderNodePoolWizardConfirmStep(width int) string { return content.String() } -// renderCleanupConfirmation renders the cleanup confirmation dialog +// ========== Volume Wizard Render Functions ========== + +func (m Model) renderVolumeWizardRegionStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select region for the volume:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, region := range m.wizard.regions { + name := getString(region, "name") + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + name)) + } else { + content.WriteString(listStyle.Render(" " + name)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter Select • Esc Cancel")) + return content.String() +} + +func (m Model) renderVolumeWizardTypeStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select volume type:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, vt := range m.wizard.volumeTypes { + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + vt)) + } else { + content.WriteString(listStyle.Render(" " + vt)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter Select • ← Back • Esc Cancel")) + return content.String() +} + +func (m Model) renderVolumeWizardAZStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select availability zone:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + items := m.wizard.volumeAvailabilityZones + // Prepend a "No preference" option + allItems := append([]string{"(No preference)"}, items...) + for i, az := range allItems { + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + az)) + } else { + content.WriteString(listStyle.Render(" " + az)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter Select • ← Back • Esc Cancel")) + return content.String() +} + +func (m Model) renderVolumeWizardConfigStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Configure volume:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7B68EE")). + Padding(0, 1). + Width(40) + activeInputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + // Name field + content.WriteString(labelStyle.Render("Name:") + "\n") + if m.wizard.volumeConfigFieldIdx == 0 { + content.WriteString(activeInputStyle.Render(m.wizard.volumeNameInput+"▌") + "\n\n") + } else { + nameDisplay := m.wizard.volumeNameInput + if nameDisplay == "" { + nameDisplay = m.wizard.volumeName + } + content.WriteString(inputStyle.Render(nameDisplay) + "\n\n") + } + + // Size field + content.WriteString(labelStyle.Render("Size (GB):") + "\n") + if m.wizard.volumeConfigFieldIdx == 1 { + content.WriteString(activeInputStyle.Render(m.wizard.volumeSizeInput+"▌") + "\n\n") + } else { + sizeDisplay := m.wizard.volumeSizeInput + if sizeDisplay == "" && m.wizard.volumeSize > 0 { + sizeDisplay = fmt.Sprintf("%d", m.wizard.volumeSize) + } + content.WriteString(inputStyle.Render(sizeDisplay) + "\n\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Tab/↓: Next field • Enter: Confirm • ← Back • Esc Cancel")) + return content.String() +} + +func (m Model) renderVolumeWizardConfirmStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Confirm volume creation:") + "\n\n") + + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.volumeName) + "\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") + content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(m.wizard.volumeType) + "\n") + + azDisplay := "(No preference)" + if m.wizard.volumeAvailabilityZone != "" { + azDisplay = m.wizard.volumeAvailabilityZone + } + content.WriteString(labelStyle.Render(" Avail. Zone:") + valueStyle.Render(azDisplay) + "\n") + content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.volumeSize)) + "\n") + + content.WriteString("\n") + + createStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + if m.wizard.volumeConfirmBtnIdx == 0 { + content.WriteString(createStyle.Render(" ▶ [Create Volume]") + " ") + content.WriteString(dimStyle.Render("[Cancel]") + "\n") + } else { + content.WriteString(dimStyle.Render(" [Create Volume]") + " ") + content.WriteString(cancelStyle.Render("▶ [Cancel]") + "\n") + } + + return content.String() +} + func (m Model) renderCleanupConfirmation(width int) string { var content strings.Builder @@ -3273,7 +3553,7 @@ func (m Model) getProductCreationInfo() (string, string) { case ProductManagedAnalytics: return "analytics", fmt.Sprintf("ovhcloud cloud managed-analytics create --cloud-project %s", m.cloudProject) case ProductStorage: - return "storage containers", fmt.Sprintf("ovhcloud cloud storage-s3 create --cloud-project %s", m.cloudProject) + return "block storage volumes", "" case ProductNetworks: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) default: @@ -3356,6 +3636,11 @@ func (m Model) renderDetailView(width int) string { return m.renderKubernetesDetail(width) case ProductProjects: return m.renderProjectDetail(width) + case ProductStorage: + if m.volumeDetailView != nil { + return m.volumeDetailView.Render(width, 0) + } + return m.renderGenericDetail(width) default: return m.renderGenericDetail(width) } @@ -3789,6 +4074,16 @@ func (m Model) renderFooter() string { help = "Type: Enter name • Enter: Confirm • ←: Back • Esc: Cancel" } else if m.wizard.step == WizardStepConfirm { help = "←→: Select • d: Debug • Enter: Confirm • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepRegion { + help = "↑↓: Navigate • Enter: Select • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepType { + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepAvailabilityZone { + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepConfig { + help = "Tab/↓: Next field • Enter: Confirm • ←: Back • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepConfirm { + help = "←→: Select • Enter: Confirm • Esc: Cancel" } else { help = "↑↓: Navigate • d: Debug • Enter: Select • ←: Back • Esc: Cancel" } @@ -3869,6 +4164,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleKubeKubeconfigPickerKeyPress(msg) } + // Delegate to block storage detail view when in DetailView for ProductStorage + if m.mode == DetailView && m.currentProduct == ProductStorage && m.volumeDetailView != nil { + cmd := m.volumeDetailView.HandleKey(msg) + return m, cmd + } + switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -4088,6 +4389,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.currentItemName = getStringValue(m.detailData, "name", "Item") m.mode = DetailView + // If viewing a block storage volume, init the detail view + if m.currentProduct == ProductStorage { + ctx := &views.Context{Width: m.width, Height: m.height} + m.volumeDetailView = block_storage.NewDetailView(ctx, m.detailData) + return m, nil + } + // If viewing a Kubernetes cluster, also load node pools if m.currentProduct == ProductKubernetes { kubeId := getStringValue(m.detailData, "id", "") @@ -4417,13 +4725,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepConfig && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepConfig && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -4441,7 +4749,10 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 100 { + if m.wizard.step >= 300 { + // Volume wizard + returnPath = "/storage/block" + } else if m.wizard.step >= 100 { // Kubernetes wizard returnPath = "/kubernetes" } @@ -4495,6 +4806,17 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleNodePoolWizardOptionsKeys(key) case NodePoolWizardStepConfirm: return m.handleNodePoolWizardConfirmKeys(key) + // Volume wizard steps + case VolumeWizardStepRegion: + return m.handleVolumeWizardRegionKeys(key, msg) + case VolumeWizardStepType: + return m.handleVolumeWizardTypeKeys(key, msg) + case VolumeWizardStepAvailabilityZone: + return m.handleVolumeWizardAZKeys(key, msg) + case VolumeWizardStepConfig: + return m.handleVolumeWizardConfigKeys(msg) + case VolumeWizardStepConfirm: + return m.handleVolumeWizardConfirmKeys(key) } return m, nil @@ -5841,7 +6163,7 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { m.currentData = nil // Show coming soon view for unimplemented products - if currentNav.Product == ProductStorage || currentNav.Product == ProductNetworks { + if currentNav.Product == ProductNetworks { m.mode = ComingSoonView return m, nil } @@ -6084,6 +6406,223 @@ func (m Model) handleNodePoolWizardConfirmKeys(key string) (tea.Model, tea.Cmd) return m, nil } +// ========== Volume Wizard Key Handlers ========== + +func (m Model) handleVolumeWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + regions := m.wizard.regions + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(regions)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(regions) == 0 { + return m, nil + } + selected := regions[m.wizard.selectedIndex] + m.wizard.selectedRegion = getString(selected, "name") + m.wizard.errorMsg = "" + if types, ok := m.wizard.volumeRegionTypeMap[m.wizard.selectedRegion]; ok { + m.wizard.volumeTypes = types + m.wizard.step = VolumeWizardStepType + m.wizard.selectedIndex = 0 + } else { + // Fallback: fetch on demand + m.wizard.step = VolumeWizardStepType + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading volume types..." + return m, m.fetchVolumeTypes(m.wizard.selectedRegion) + } + } + return m, nil +} + +func (m Model) handleVolumeWizardTypeKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + types := m.wizard.volumeTypes + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(types)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(types) == 0 { + return m, nil + } + m.wizard.volumeType = types[m.wizard.selectedIndex] + m.wizard.errorMsg = "" + m.wizard.step = VolumeWizardStepAvailabilityZone + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading availability zones..." + return m, m.fetchVolumeAvailabilityZones(m.wizard.selectedRegion) + case "left": + m.wizard.step = VolumeWizardStepRegion + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleVolumeWizardAZKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + // Items = "(No preference)" + actual AZs + totalItems := 1 + len(m.wizard.volumeAvailabilityZones) + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < totalItems-1 { + m.wizard.selectedIndex++ + } + case "enter": + if m.wizard.selectedIndex == 0 { + m.wizard.volumeAvailabilityZone = "" // No preference + } else { + m.wizard.volumeAvailabilityZone = m.wizard.volumeAvailabilityZones[m.wizard.selectedIndex-1] + } + m.wizard.errorMsg = "" + m.wizard.step = VolumeWizardStepConfig + m.wizard.volumeConfigFieldIdx = 0 + m.wizard.volumeNameInput = m.wizard.volumeName + if m.wizard.volumeSize > 0 { + m.wizard.volumeSizeInput = fmt.Sprintf("%d", m.wizard.volumeSize) + } else { + m.wizard.volumeSizeInput = "" + } + case "left": + m.wizard.step = VolumeWizardStepType + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleVolumeWizardConfigKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "tab", "down": + if m.wizard.volumeConfigFieldIdx == 0 { + // Commit name field + name := strings.TrimSpace(m.wizard.volumeNameInput) + if name != "" { + m.wizard.volumeName = name + } + m.wizard.volumeConfigFieldIdx = 1 + } else { + m.wizard.volumeConfigFieldIdx = 0 + } + case "up": + if m.wizard.volumeConfigFieldIdx == 1 { + m.wizard.volumeConfigFieldIdx = 0 + } + case "enter": + if m.wizard.volumeConfigFieldIdx == 0 { + // On name field: validate name then move to size field + name := strings.TrimSpace(m.wizard.volumeNameInput) + if name == "" { + m.wizard.errorMsg = "Volume name cannot be empty" + return m, nil + } + m.wizard.volumeName = name + m.wizard.errorMsg = "" + m.wizard.volumeConfigFieldIdx = 1 + return m, nil + } + // On size field: validate both and proceed + name := strings.TrimSpace(m.wizard.volumeNameInput) + sizeStr := strings.TrimSpace(m.wizard.volumeSizeInput) + size, sizeErr := strconv.Atoi(sizeStr) + if name == "" { + m.wizard.errorMsg = "Volume name cannot be empty" + m.wizard.volumeConfigFieldIdx = 0 + return m, nil + } + if sizeErr != nil || size < 1 { + m.wizard.errorMsg = "Size must be a positive integer (GB)" + return m, nil + } + m.wizard.volumeName = name + m.wizard.volumeSize = size + m.wizard.errorMsg = "" + m.wizard.step = VolumeWizardStepConfirm + m.wizard.volumeConfirmBtnIdx = 0 + case "backspace": + if m.wizard.volumeConfigFieldIdx == 0 { + if len(m.wizard.volumeNameInput) > 0 { + m.wizard.volumeNameInput = m.wizard.volumeNameInput[:len(m.wizard.volumeNameInput)-1] + } + } else { + if len(m.wizard.volumeSizeInput) > 0 { + m.wizard.volumeSizeInput = m.wizard.volumeSizeInput[:len(m.wizard.volumeSizeInput)-1] + } + } + case "left": + m.wizard.step = VolumeWizardStepAvailabilityZone + m.wizard.selectedIndex = 0 + default: + // Append printable rune to the active field + r := msg.Runes + if len(r) > 0 { + ch := string(r) + if m.wizard.volumeConfigFieldIdx == 0 { + m.wizard.volumeNameInput += ch + } else { + // Only allow digits for size + if ch >= "0" && ch <= "9" { + m.wizard.volumeSizeInput += ch + } + } + } + } + return m, nil +} + +func (m Model) handleVolumeWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + switch key { + case "right", "tab": + if m.wizard.volumeConfirmBtnIdx == 0 { + m.wizard.volumeConfirmBtnIdx = 1 + } else { + m.wizard.volumeConfirmBtnIdx = 0 + } + case "enter": + if m.wizard.volumeConfirmBtnIdx == 0 { + // Create the volume + m.wizard.isLoading = true + m.wizard.loadingMessage = "Creating volume..." + return m, m.createVolume() + } + // Cancel + m.wizard = WizardData{} + m.mode = LoadingView + return m, m.fetchDataForPath("/storage/block") + case "left", "esc": + m.wizard.step = VolumeWizardStepConfig + m.wizard.volumeConfigFieldIdx = 0 + } + return m, nil +} + // getNumericValue extracts a numeric value from a map, handling json.Number type func getNumericValue(data map[string]interface{}, key string) float64 { if val, ok := data[key]; ok { diff --git a/internal/services/browser/views/block_storage/detail.go b/internal/services/browser/views/block_storage/detail.go new file mode 100644 index 00000000..1eb1e977 --- /dev/null +++ b/internal/services/browser/views/block_storage/detail.go @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package block_storage + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +// Action indices for volume detail view +const ( + VolumeActionDelete = iota + VolumeActionRename + VolumeActionExtend +) + +var volumeActionLabels = []string{"Delete", "Rename", "Extend"} + +// DetailView displays block storage volume details with actions. +type DetailView struct { + views.BaseView + volume map[string]interface{} + selectedAction int + confirmMode bool + renameMode bool + renameInput string + extendMode bool + extendInput string +} + +func NewDetailView(ctx *views.Context, volume map[string]interface{}) *DetailView { + return &DetailView{ + BaseView: views.NewBaseView(ctx), + volume: volume, + selectedAction: 0, + confirmMode: false, + } +} + +func (v *DetailView) Render(width, height int) string { + var content strings.Builder + + if v.volume == nil { + return views.StyleError.Render("No volume data available") + } + + id := getString(v.volume, "id") + status := getString(v.volume, "status") + region := getString(v.volume, "region") + vType := getString(v.volume, "type") + createdAt := getString(v.volume, "createdAt") + description := getString(v.volume, "description") + size := getSizeStr(v.volume) + bootable := getBootable(v.volume) + + var infoContent strings.Builder + infoContent.WriteString(views.RenderKeyValue("ID", id) + "\n") + infoContent.WriteString(views.RenderKeyValue("Status", views.RenderStatus(status)) + "\n") + infoContent.WriteString(views.RenderKeyValue("Region", region) + "\n") + infoContent.WriteString(views.RenderKeyValue("Type", vType) + "\n") + infoContent.WriteString(views.RenderKeyValue("Size", size+" GB") + "\n") + infoContent.WriteString(views.RenderKeyValue("Bootable", bootable) + "\n") + if description != "" { + infoContent.WriteString(views.RenderKeyValue("Description", description) + "\n") + } + infoContent.WriteString(views.RenderKeyValue("Created", createdAt) + "\n") + content.WriteString(views.RenderBox("Volume Information", infoContent.String(), width-4)) + content.WriteString("\n\n") + + attachments := getAttachedTo(v.volume) + var attachContent strings.Builder + if len(attachments) > 0 { + for _, instanceID := range attachments { + attachContent.WriteString(fmt.Sprintf(" • %s\n", instanceID)) + } + } else { + attachContent.WriteString(" Not attached to any instance\n") + } + content.WriteString(views.RenderBox(fmt.Sprintf("Attached to (%d)", len(attachments)), attachContent.String(), width-4)) + content.WriteString("\n\n") + + actionsContent := v.renderActions() + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4)) + + return content.String() +} + +func (v *DetailView) renderActions() string { + if v.renameMode { + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + return views.StyleStatusWarning.Render("New name:") + "\n" + + inputStyle.Render(v.renameInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") + } + + if v.extendMode { + currentSize := getSizeStr(v.volume) + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(20) + return views.StyleStatusWarning.Render(fmt.Sprintf("New size in GB (current: %s):", currentSize)) + "\n" + + inputStyle.Render(v.extendInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") + } + + var parts []string + + for i, label := range volumeActionLabels { + var style lipgloss.Style + if i == v.selectedAction { + style = views.StyleButtonSelected + } else if label == "Delete" { + style = views.StyleButtonDanger + } else { + style = views.StyleButton + } + parts = append(parts, style.Render("["+label+"]")) + } + + result := strings.Join(parts, " ") + + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render( + fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", volumeActionLabels[v.selectedAction])) + } + + return result +} + +func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + if v.renameMode { + switch msg.Type { + case tea.KeyEscape: + v.renameMode = false + v.renameInput = "" + case tea.KeyEnter: + if v.renameInput != "" { + name := v.renameInput + v.renameMode = false + v.renameInput = "" + return func() tea.Msg { + return ExecuteVolumeActionMsg{ + Volume: v.volume, + Action: VolumeActionRename, + Param: name, + } + } + } + case tea.KeyBackspace: + if len(v.renameInput) > 0 { + v.renameInput = v.renameInput[:len(v.renameInput)-1] + } + case tea.KeyRunes: + v.renameInput += string(msg.Runes) + } + return nil + } + + if v.extendMode { + switch msg.Type { + case tea.KeyEscape: + v.extendMode = false + v.extendInput = "" + case tea.KeyEnter: + if v.extendInput != "" { + size := v.extendInput + v.extendMode = false + v.extendInput = "" + return func() tea.Msg { + return ExecuteVolumeActionMsg{ + Volume: v.volume, + Action: VolumeActionExtend, + Param: size, + } + } + } + case tea.KeyBackspace: + if len(v.extendInput) > 0 { + v.extendInput = v.extendInput[:len(v.extendInput)-1] + } + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + v.extendInput += string(r) + } + } + } + return nil + } + + switch key { + case "left": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + return nil + case "right": + if v.selectedAction < len(volumeActionLabels)-1 { + v.selectedAction++ + v.confirmMode = false + } + return nil + case "enter": + if v.confirmMode { + v.confirmMode = false + return func() tea.Msg { + return ExecuteVolumeActionMsg{ + Volume: v.volume, + Action: v.selectedAction, + } + } + } + switch v.selectedAction { + case VolumeActionDelete: + v.confirmMode = true + case VolumeActionRename: + v.renameInput = getString(v.volume, "name") + v.renameMode = true + case VolumeActionExtend: + v.extendInput = getSizeStr(v.volume) + v.extendMode = true + } + return nil + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + return func() tea.Msg { + return views.GoBackMsg{} + } + } + return nil +} + +func (v *DetailView) Title() string { + name := getString(v.volume, "name") + return fmt.Sprintf(" 💾 Block Storage > %s ", name) +} + +func (v *DetailView) HelpText() string { + if v.renameMode || v.extendMode { + return "Type value • Enter: Confirm • Esc: Cancel" + } + if v.confirmMode { + return "Enter: Confirm Action • Esc: Cancel" + } + return "←→: Select Action • Enter: Execute • Esc: Back to List • q: Quit" +} + +type ExecuteVolumeActionMsg struct { + Volume map[string]interface{} + Action int + Param string +} + +func getBootable(volume map[string]interface{}) string { + if b, ok := volume["bootable"].(bool); ok { + if b { + return "Yes" + } + return "No" + } + return "-" +} + +func getAttachedTo(volume map[string]interface{}) []string { + var result []string + if raw, ok := volume["attachedTo"].([]interface{}); ok { + for _, item := range raw { + if id, ok := item.(string); ok { + result = append(result, id) + } + } + } + return result +} diff --git a/internal/services/browser/views/block_storage/table.go b/internal/services/browser/views/block_storage/table.go new file mode 100644 index 00000000..9653fce1 --- /dev/null +++ b/internal/services/browser/views/block_storage/table.go @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package block_storage + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +type TableView struct { + views.BaseView + table table.Model + data []map[string]interface{} + filterMode bool + filterInput string + filteredData []map[string]interface{} +} + +func NewTableView(ctx *views.Context, data []map[string]interface{}) *TableView { + v := &TableView{ + BaseView: views.NewBaseView(ctx), + data: data, + filteredData: data, + } + v.table = v.createTable() + return v +} + +func (v *TableView) createTable() table.Model { + columns := []table.Column{ + {Title: "Name", Width: 28}, + {Title: "Status", Width: 12}, + {Title: "Size (GB)", Width: 10}, + {Title: "Type", Width: 18}, + {Title: "Region", Width: 12}, + } + + var rows []table.Row + for _, volume := range v.filteredData { + name := getString(volume, "name") + status := getString(volume, "status") + size := getSizeStr(volume) + vType := getString(volume, "type") + region := getString(volume, "region") + + rows = append(rows, table.Row{name, status, size, vType, region}) + } + + ctx := v.Context() + height := ctx.Height - 15 + if height < 5 { + height = 5 + } + if height > 20 { + height = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(height), + ) + + s := table.DefaultStyles() + s.Header = views.StyleTableHeader + s.Selected = views.StyleTableSelected + t.SetStyles(s) + + return t +} + +func (v *TableView) Render(width, height int) string { + var content strings.Builder + + if v.filterMode { + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")) + content.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▌", v.filterInput)) + "\n\n") + } else if v.filterInput != "" { + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s (press / to edit)", v.filterInput)) + "\n\n") + } + + content.WriteString(v.table.View()) + + return content.String() +} + +func (v *TableView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + if v.filterMode { + switch msg.Type { + case tea.KeyEscape: + v.filterMode = false + return nil + case tea.KeyEnter: + v.filterMode = false + v.applyFilter() + return nil + case tea.KeyBackspace: + if len(v.filterInput) > 0 { + v.filterInput = v.filterInput[:len(v.filterInput)-1] + } + v.applyFilter() + return nil + case tea.KeyRunes: + v.filterInput += string(msg.Runes) + v.applyFilter() + return nil + } + return nil + } + + switch key { + case "/": + v.filterMode = true + return nil + case "enter": + idx := v.table.Cursor() + if idx >= 0 && idx < len(v.filteredData) { + return func() tea.Msg { + return ShowVolumeDetailMsg{Volume: v.filteredData[idx]} + } + } + case "up", "down", "j", "k": + var cmd tea.Cmd + v.table, cmd = v.table.Update(msg) + return cmd + case "esc": + if v.filterInput != "" { + v.filterInput = "" + v.applyFilter() + return nil + } + } + return nil +} + +func (v *TableView) applyFilter() { + if v.filterInput == "" { + v.filteredData = v.data + } else { + filter := strings.ToLower(v.filterInput) + v.filteredData = nil + for _, item := range v.data { + name := strings.ToLower(getString(item, "name")) + if strings.Contains(name, filter) { + v.filteredData = append(v.filteredData, item) + } + } + } + v.table = v.createTable() +} + +func (v *TableView) Title() string { + return " 💾 Block Storage " +} + +func (v *TableView) HelpText() string { + if v.filterMode { + return "Type to filter • Enter: Confirm • Esc: Cancel" + } + return "↑↓: Navigate • /: Filter • Enter: Details • d: Debug • p: Projects • q: Quit" +} + +func (v *TableView) GetSelectedVolume() map[string]interface{} { + idx := v.table.Cursor() + if idx >= 0 && idx < len(v.filteredData) { + return v.filteredData[idx] + } + return nil +} + +// UpdateData updates the table with new data. +func (v *TableView) UpdateData(data []map[string]interface{}) { + cursor := v.table.Cursor() + v.data = data + v.applyFilter() + if cursor >= 0 && cursor < len(v.filteredData) { + v.table.SetCursor(cursor) + } +} + +type ShowVolumeDetailMsg struct { + Volume map[string]interface{} +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getSizeStr(volume map[string]interface{}) string { + switch v := volume["size"].(type) { + case float64: + return fmt.Sprintf("%d", int(v)) + case int: + return fmt.Sprintf("%d", v) + } + return "-" +} From 3a393e2ac6f55bfc8343bca6a4828fd43f106117 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Fri, 24 Apr 2026 14:59:09 +0000 Subject: [PATCH 03/55] feat(browser): fixed refresh and fixed creation Signed-off-by: olivier dubo --- internal/services/browser/api.go | 15 +- internal/services/browser/manager.go | 216 ++++++++++++--------------- 2 files changed, 108 insertions(+), 123 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index f5edaf2b..469df078 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -817,8 +817,16 @@ func (m Model) fetchBlockStorageData() dataLoadedMsg { } } + var filtered []map[string]interface{} + for _, v := range volumes { + status := getString(v, "status") + if status != "deleting" && status != "deleted" { + filtered = append(filtered, v) + } + } + return dataLoadedMsg{ - data: volumes, + data: filtered, err: nil, } } @@ -1037,7 +1045,9 @@ func (m Model) handleVolumeCreated(msg volumeCreatedMsg) (tea.Model, tea.Cmd) { m.wizard = WizardData{} m.mode = LoadingView return m, tea.Batch( - m.fetchDataForPath("/storage/block"), + tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return refreshBlockStorageMsg{} + }), tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), @@ -1088,6 +1098,7 @@ func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.C m.notification = fmt.Sprintf("✅ Volume %s successfully!", actionName) m.notificationExpiry = time.Now().Add(5 * time.Second) m.volumeDetailView = nil + m.detailData = nil m.mode = LoadingView return m, tea.Batch( m.fetchDataForPath("/storage/block"), diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index a670a817..38c0a286 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -91,10 +91,11 @@ const ( NodePoolWizardStepOptions NodePoolWizardStepConfirm // Volume (Block Storage) wizard steps (offset by 300) - VolumeWizardStepRegion WizardStep = iota + 300 + VolumeWizardStepName WizardStep = iota + 300 + VolumeWizardStepRegion VolumeWizardStepType VolumeWizardStepAvailabilityZone - VolumeWizardStepConfig + VolumeWizardStepSize VolumeWizardStepConfirm ) @@ -249,7 +250,6 @@ type WizardData struct { volumeSizeInput string // Input buffer for volume size volumeType string // Selected volume type volumeAvailabilityZone string // Selected availability zone - volumeConfigFieldIdx int // 0 = name, 1 = size volumeConfirmBtnIdx int // 0 = Create, 1 = Cancel } @@ -578,6 +578,8 @@ type volumeActionDoneMsg struct { err error } +type refreshBlockStorageMsg struct{} + // Navigation items for products (shown after project is selected) func getNavItems() []NavItem { return []NavItem{ @@ -716,11 +718,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if msg.product == ProductStorage { m.mode = WizardView m.wizard = WizardData{ - step: VolumeWizardStepRegion, - isLoading: true, - loadingMessage: "Loading regions...", + step: VolumeWizardStepName, } - return m, m.fetchVolumeRegions() + return m, nil } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -889,6 +889,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case volumeActionDoneMsg: return m.handleVolumeActionDone(msg) + case refreshBlockStorageMsg: + return m, m.fetchDataForPath("/storage/block") + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -1955,8 +1958,8 @@ func (m Model) renderWizardView(width int) string { // Build steps based on which wizard we're in (determine by first step >= 100) if m.wizard.step >= 300 { // Volume wizard - steps = append(steps, "Region", "Type", "Avail. Zone", "Config", "Confirm") - stepMapping = append(stepMapping, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepConfig, VolumeWizardStepConfirm) + steps = append(steps, "Name", "Region", "Type", "Avail. Zone", "Size", "Confirm") + stepMapping = append(stepMapping, VolumeWizardStepName, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepSize, VolumeWizardStepConfirm) } else if m.wizard.step >= 200 { // Node pool wizard steps = append(steps, "Flavor", "Name", "Size", "Options", "Confirm") @@ -2068,14 +2071,16 @@ func (m Model) renderWizardView(width int) string { case NodePoolWizardStepConfirm: content.WriteString(m.renderNodePoolWizardConfirmStep(width)) // Volume wizard steps + case VolumeWizardStepName: + content.WriteString(m.renderVolumeWizardNameStep(width)) case VolumeWizardStepRegion: content.WriteString(m.renderVolumeWizardRegionStep(width)) case VolumeWizardStepType: content.WriteString(m.renderVolumeWizardTypeStep(width)) case VolumeWizardStepAvailabilityZone: content.WriteString(m.renderVolumeWizardAZStep(width)) - case VolumeWizardStepConfig: - content.WriteString(m.renderVolumeWizardConfigStep(width)) + case VolumeWizardStepSize: + content.WriteString(m.renderVolumeWizardSizeStep(width)) case VolumeWizardStepConfirm: content.WriteString(m.renderVolumeWizardConfirmStep(width)) } @@ -3312,6 +3317,29 @@ func (m Model) renderNodePoolWizardConfirmStep(width int) string { // ========== Volume Wizard Render Functions ========== +func (m Model) renderVolumeWizardNameStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Enter a name for the volume:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + content.WriteString(inputStyle.Render(m.wizard.volumeNameInput+"▌") + "\n\n") + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Type to enter • Enter: Continue • Esc: Cancel")) + return content.String() +} + func (m Model) renderVolumeWizardRegionStep(width int) string { var content strings.Builder @@ -3400,11 +3428,11 @@ func (m Model) renderVolumeWizardAZStep(width int) string { return content.String() } -func (m Model) renderVolumeWizardConfigStep(width int) string { +func (m Model) renderVolumeWizardSizeStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Configure volume:") + "\n\n") + content.WriteString(titleStyle.Render("Enter volume size (GB):") + "\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) @@ -3412,44 +3440,14 @@ func (m Model) renderVolumeWizardConfigStep(width int) string { } inputStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#7B68EE")). - Padding(0, 1). - Width(40) - activeInputStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#00FF7F")). Padding(0, 1). - Width(40) - - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - - // Name field - content.WriteString(labelStyle.Render("Name:") + "\n") - if m.wizard.volumeConfigFieldIdx == 0 { - content.WriteString(activeInputStyle.Render(m.wizard.volumeNameInput+"▌") + "\n\n") - } else { - nameDisplay := m.wizard.volumeNameInput - if nameDisplay == "" { - nameDisplay = m.wizard.volumeName - } - content.WriteString(inputStyle.Render(nameDisplay) + "\n\n") - } - - // Size field - content.WriteString(labelStyle.Render("Size (GB):") + "\n") - if m.wizard.volumeConfigFieldIdx == 1 { - content.WriteString(activeInputStyle.Render(m.wizard.volumeSizeInput+"▌") + "\n\n") - } else { - sizeDisplay := m.wizard.volumeSizeInput - if sizeDisplay == "" && m.wizard.volumeSize > 0 { - sizeDisplay = fmt.Sprintf("%d", m.wizard.volumeSize) - } - content.WriteString(inputStyle.Render(sizeDisplay) + "\n\n") - } + Width(20) + content.WriteString(inputStyle.Render(m.wizard.volumeSizeInput+"▌") + "\n\n") helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(helpStyle.Render("Tab/↓: Next field • Enter: Confirm • ← Back • Esc Cancel")) + content.WriteString(helpStyle.Render("Type size • Enter: Continue • ←: Back • Esc: Cancel")) return content.String() } @@ -4074,14 +4072,16 @@ func (m Model) renderFooter() string { help = "Type: Enter name • Enter: Confirm • ←: Back • Esc: Cancel" } else if m.wizard.step == WizardStepConfirm { help = "←→: Select • d: Debug • Enter: Confirm • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepName { + help = "Type: Enter name • Enter: Confirm • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepRegion { - help = "↑↓: Navigate • Enter: Select • Esc: Cancel" + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepType { help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepAvailabilityZone { help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" - } else if m.wizard.step == VolumeWizardStepConfig { - help = "Tab/↓: Next field • Enter: Confirm • ←: Back • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepSize { + help = "Type size in GB • Enter: Confirm • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepConfirm { help = "←→: Select • Enter: Confirm • Esc: Cancel" } else { @@ -4725,13 +4725,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepConfig && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepConfig && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -4807,14 +4807,16 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case NodePoolWizardStepConfirm: return m.handleNodePoolWizardConfirmKeys(key) // Volume wizard steps + case VolumeWizardStepName: + return m.handleVolumeWizardNameKeys(msg) case VolumeWizardStepRegion: return m.handleVolumeWizardRegionKeys(key, msg) case VolumeWizardStepType: return m.handleVolumeWizardTypeKeys(key, msg) case VolumeWizardStepAvailabilityZone: return m.handleVolumeWizardAZKeys(key, msg) - case VolumeWizardStepConfig: - return m.handleVolumeWizardConfigKeys(msg) + case VolumeWizardStepSize: + return m.handleVolumeWizardSizeKeys(msg) case VolumeWizardStepConfirm: return m.handleVolumeWizardConfirmKeys(key) } @@ -6408,6 +6410,31 @@ func (m Model) handleNodePoolWizardConfirmKeys(key string) (tea.Model, tea.Cmd) // ========== Volume Wizard Key Handlers ========== +func (m Model) handleVolumeWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + name := strings.TrimSpace(m.wizard.volumeNameInput) + if name == "" { + m.wizard.errorMsg = "Volume name cannot be empty" + return m, nil + } + m.wizard.volumeName = name + m.wizard.errorMsg = "" + m.wizard.step = VolumeWizardStepRegion + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading regions..." + return m, m.fetchVolumeRegions() + case tea.KeyBackspace: + if len(m.wizard.volumeNameInput) > 0 { + m.wizard.volumeNameInput = m.wizard.volumeNameInput[:len(m.wizard.volumeNameInput)-1] + } + case tea.KeyRunes: + m.wizard.volumeNameInput += string(msg.Runes) + } + return m, nil +} + func (m Model) handleVolumeWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.wizard.isLoading { return m, nil @@ -6499,9 +6526,7 @@ func (m Model) handleVolumeWizardAZKeys(key string, msg tea.KeyMsg) (tea.Model, m.wizard.volumeAvailabilityZone = m.wizard.volumeAvailabilityZones[m.wizard.selectedIndex-1] } m.wizard.errorMsg = "" - m.wizard.step = VolumeWizardStepConfig - m.wizard.volumeConfigFieldIdx = 0 - m.wizard.volumeNameInput = m.wizard.volumeName + m.wizard.step = VolumeWizardStepSize if m.wizard.volumeSize > 0 { m.wizard.volumeSizeInput = fmt.Sprintf("%d", m.wizard.volumeSize) } else { @@ -6514,80 +6539,30 @@ func (m Model) handleVolumeWizardAZKeys(key string, msg tea.KeyMsg) (tea.Model, return m, nil } -func (m Model) handleVolumeWizardConfigKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - switch key { - case "tab", "down": - if m.wizard.volumeConfigFieldIdx == 0 { - // Commit name field - name := strings.TrimSpace(m.wizard.volumeNameInput) - if name != "" { - m.wizard.volumeName = name - } - m.wizard.volumeConfigFieldIdx = 1 - } else { - m.wizard.volumeConfigFieldIdx = 0 - } - case "up": - if m.wizard.volumeConfigFieldIdx == 1 { - m.wizard.volumeConfigFieldIdx = 0 - } - case "enter": - if m.wizard.volumeConfigFieldIdx == 0 { - // On name field: validate name then move to size field - name := strings.TrimSpace(m.wizard.volumeNameInput) - if name == "" { - m.wizard.errorMsg = "Volume name cannot be empty" - return m, nil - } - m.wizard.volumeName = name - m.wizard.errorMsg = "" - m.wizard.volumeConfigFieldIdx = 1 - return m, nil - } - // On size field: validate both and proceed - name := strings.TrimSpace(m.wizard.volumeNameInput) +func (m Model) handleVolumeWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: sizeStr := strings.TrimSpace(m.wizard.volumeSizeInput) - size, sizeErr := strconv.Atoi(sizeStr) - if name == "" { - m.wizard.errorMsg = "Volume name cannot be empty" - m.wizard.volumeConfigFieldIdx = 0 - return m, nil - } - if sizeErr != nil || size < 1 { + size, err := strconv.Atoi(sizeStr) + if err != nil || size < 1 { m.wizard.errorMsg = "Size must be a positive integer (GB)" return m, nil } - m.wizard.volumeName = name m.wizard.volumeSize = size m.wizard.errorMsg = "" m.wizard.step = VolumeWizardStepConfirm m.wizard.volumeConfirmBtnIdx = 0 - case "backspace": - if m.wizard.volumeConfigFieldIdx == 0 { - if len(m.wizard.volumeNameInput) > 0 { - m.wizard.volumeNameInput = m.wizard.volumeNameInput[:len(m.wizard.volumeNameInput)-1] - } - } else { - if len(m.wizard.volumeSizeInput) > 0 { - m.wizard.volumeSizeInput = m.wizard.volumeSizeInput[:len(m.wizard.volumeSizeInput)-1] - } + case tea.KeyBackspace: + if len(m.wizard.volumeSizeInput) > 0 { + m.wizard.volumeSizeInput = m.wizard.volumeSizeInput[:len(m.wizard.volumeSizeInput)-1] } - case "left": + case tea.KeyLeft: m.wizard.step = VolumeWizardStepAvailabilityZone m.wizard.selectedIndex = 0 - default: - // Append printable rune to the active field - r := msg.Runes - if len(r) > 0 { - ch := string(r) - if m.wizard.volumeConfigFieldIdx == 0 { - m.wizard.volumeNameInput += ch - } else { - // Only allow digits for size - if ch >= "0" && ch <= "9" { - m.wizard.volumeSizeInput += ch - } + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + m.wizard.volumeSizeInput += string(r) } } } @@ -6617,8 +6592,7 @@ func (m Model) handleVolumeWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { m.mode = LoadingView return m, m.fetchDataForPath("/storage/block") case "left", "esc": - m.wizard.step = VolumeWizardStepConfig - m.wizard.volumeConfigFieldIdx = 0 + m.wizard.step = VolumeWizardStepSize } return m, nil } From 6ded72912c22dc3e78e4f2d88d00da21c3b501f0 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 27 Apr 2026 08:54:26 +0000 Subject: [PATCH 04/55] feat(browser): added encription status Signed-off-by: olivier dubo --- internal/services/browser/api.go | 28 ++++- internal/services/browser/manager.go | 110 +++++++++++++++++- .../browser/views/block_storage/detail.go | 26 ++++- .../browser/views/block_storage/table.go | 5 + 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 469df078..b001dcf8 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -904,7 +904,7 @@ func (m Model) fetchVolumeTypes(region string) tea.Cmd { } var types []string for _, t := range rawTypes { - if n, ok := t["name"].(string); ok && n != "" { + if n, ok := t["name"].(string); ok && n != "" && !strings.HasSuffix(n, "-luks") { types = append(types, n) } } @@ -941,10 +941,14 @@ func (m Model) createVolume() tea.Cmd { if m.cloudProject == "" { return volumeCreatedMsg{err: fmt.Errorf("no cloud project selected")} } + effectiveType := m.wizard.volumeType + if m.wizard.volumeEncryptionIdx == 1 && !strings.HasSuffix(effectiveType, "-luks") { + effectiveType += "-luks" + } body := map[string]interface{}{ "name": m.wizard.volumeName, "size": m.wizard.volumeSize, - "type": m.wizard.volumeType, + "type": effectiveType, } if m.wizard.volumeAvailabilityZone != "" { body["availabilityZone"] = m.wizard.volumeAvailabilityZone @@ -1761,6 +1765,7 @@ func createBlockStorageTable(data []map[string]interface{}, width, height int) t {Title: "Type", Width: 14}, {Title: "Capacité", Width: 10}, {Title: "Instance", Width: 20}, + {Title: "Chiffrement", Width: 20}, {Title: "Statut", Width: 12}, } @@ -1770,9 +1775,16 @@ func createBlockStorageTable(data []map[string]interface{}, width, height int) t id := getString(vol, "id") region := getString(vol, "region") vType := getString(vol, "type") - size := "" - if s, ok := vol["size"]; ok { - size = fmt.Sprintf("%v GB", s) + size := "-" + switch v := vol["size"].(type) { + case float64: + size = fmt.Sprintf("%d GB", int(v)) + case int: + size = fmt.Sprintf("%d GB", v) + case json.Number: + if i, err := v.Int64(); err == nil { + size = fmt.Sprintf("%d GB", i) + } } instance := "-" if raw, ok := vol["attachedTo"].([]interface{}); ok && len(raw) > 0 { @@ -1785,7 +1797,11 @@ func createBlockStorageTable(data []map[string]interface{}, width, height int) t } } status := getString(vol, "status") - rows = append(rows, table.Row{name, id, region, vType, size, instance, status}) + encryption := "Aucun" + if strings.HasSuffix(vType, "-luks") { + encryption = "Actif" + } + rows = append(rows, table.Row{name, id, region, vType, size, instance, encryption, status}) } tableHeight := height - 15 diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 38c0a286..19d5a409 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -96,6 +96,7 @@ const ( VolumeWizardStepType VolumeWizardStepAvailabilityZone VolumeWizardStepSize + VolumeWizardStepEncryption VolumeWizardStepConfirm ) @@ -250,6 +251,7 @@ type WizardData struct { volumeSizeInput string // Input buffer for volume size volumeType string // Selected volume type volumeAvailabilityZone string // Selected availability zone + volumeEncryptionIdx int // 0=none, 1=OVHcloud Managed Key volumeConfirmBtnIdx int // 0 = Create, 1 = Cancel } @@ -1958,8 +1960,8 @@ func (m Model) renderWizardView(width int) string { // Build steps based on which wizard we're in (determine by first step >= 100) if m.wizard.step >= 300 { // Volume wizard - steps = append(steps, "Name", "Region", "Type", "Avail. Zone", "Size", "Confirm") - stepMapping = append(stepMapping, VolumeWizardStepName, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepSize, VolumeWizardStepConfirm) + steps = append(steps, "Name", "Region", "Type", "Avail. Zone", "Size", "Encryption", "Confirm") + stepMapping = append(stepMapping, VolumeWizardStepName, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepSize, VolumeWizardStepEncryption, VolumeWizardStepConfirm) } else if m.wizard.step >= 200 { // Node pool wizard steps = append(steps, "Flavor", "Name", "Size", "Options", "Confirm") @@ -2081,6 +2083,8 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderVolumeWizardAZStep(width)) case VolumeWizardStepSize: content.WriteString(m.renderVolumeWizardSizeStep(width)) + case VolumeWizardStepEncryption: + content.WriteString(m.renderVolumeWizardEncryptionStep(width)) case VolumeWizardStepConfirm: content.WriteString(m.renderVolumeWizardConfirmStep(width)) } @@ -3451,6 +3455,88 @@ func (m Model) renderVolumeWizardSizeStep(width int) string { return content.String() } +func (m Model) volumeTypeSupportLuks() bool { + // Only classic, high-speed, high-speed-gen2 have -luks variants + switch m.wizard.volumeType { + case "classic", "high-speed", "high-speed-gen2": + return true + } + return false +} + +func (m Model) renderVolumeWizardEncryptionStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Chiffrement") + "\n\n") + + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + content.WriteString(descStyle.Render("Activez le chiffrement pour ajouter une couche de sécurité à vos volumes\net assurer la confidentialité de vos informations.") + "\n\n") + + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + disabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + + luksSupported := m.volumeTypeSupportLuks() + + options := []struct { + label string + disabled bool + }{ + {"Aucun", false}, + {"OVHcloud Managed Key", !luksSupported}, + {"Customer Managed Key (Bientôt disponible)", true}, + } + + for i, opt := range options { + cursor := " " + if i == m.wizard.volumeEncryptionIdx && !opt.disabled { + cursor = "▶ " + } + var line string + if opt.disabled { + line = disabledStyle.Render(cursor + opt.label) + } else if i == m.wizard.volumeEncryptionIdx { + line = selectedStyle.Render(cursor + opt.label) + } else { + line = normalStyle.Render(cursor + opt.label) + } + content.WriteString(line + "\n") + } + + if !luksSupported { + content.WriteString("\n" + disabledStyle.Render(fmt.Sprintf(" (Le type '%s' ne supporte pas le chiffrement)", m.wizard.volumeType)) + "\n") + } + + content.WriteString("\n") + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("↑↓: Navigate • Enter: Continue • ←: Back • Esc: Cancel")) + return content.String() +} + +func (m Model) handleVolumeWizardEncryptionKeys(key string) (tea.Model, tea.Cmd) { + // If type doesn't support luks, force index to 0 + if !m.volumeTypeSupportLuks() { + m.wizard.volumeEncryptionIdx = 0 + } + switch key { + case "up": + if m.wizard.volumeEncryptionIdx > 0 { + m.wizard.volumeEncryptionIdx-- + } + case "down": + if m.wizard.volumeEncryptionIdx < 1 && m.volumeTypeSupportLuks() { + m.wizard.volumeEncryptionIdx++ + } + case "enter": + m.wizard.step = VolumeWizardStepConfirm + m.wizard.volumeConfirmBtnIdx = 0 + case "left", "esc": + m.wizard.step = VolumeWizardStepSize + } + return m, nil +} + func (m Model) renderVolumeWizardConfirmStep(width int) string { var content strings.Builder @@ -3462,7 +3548,11 @@ func (m Model) renderVolumeWizardConfirmStep(width int) string { content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.volumeName) + "\n") content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") - content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(m.wizard.volumeType) + "\n") + effectiveType := m.wizard.volumeType + if m.wizard.volumeEncryptionIdx == 1 && !strings.HasSuffix(effectiveType, "-luks") { + effectiveType += "-luks" + } + content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(effectiveType) + "\n") azDisplay := "(No preference)" if m.wizard.volumeAvailabilityZone != "" { @@ -3470,6 +3560,11 @@ func (m Model) renderVolumeWizardConfirmStep(width int) string { } content.WriteString(labelStyle.Render(" Avail. Zone:") + valueStyle.Render(azDisplay) + "\n") content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.volumeSize)) + "\n") + encLabel := "Aucun" + if m.wizard.volumeEncryptionIdx == 1 { + encLabel = "OVHcloud Managed Key (LUKS)" + } + content.WriteString(labelStyle.Render(" Encryption:") + valueStyle.Render(encLabel) + "\n") content.WriteString("\n") @@ -4082,6 +4177,8 @@ func (m Model) renderFooter() string { help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepSize { help = "Type size in GB • Enter: Confirm • ←: Back • Esc: Cancel" + } else if m.wizard.step == VolumeWizardStepEncryption { + help = "↑↓: Select • Enter: Continue • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepConfirm { help = "←→: Select • Enter: Confirm • Esc: Cancel" } else { @@ -4817,6 +4914,8 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleVolumeWizardAZKeys(key, msg) case VolumeWizardStepSize: return m.handleVolumeWizardSizeKeys(msg) + case VolumeWizardStepEncryption: + return m.handleVolumeWizardEncryptionKeys(key) case VolumeWizardStepConfirm: return m.handleVolumeWizardConfirmKeys(key) } @@ -6550,8 +6649,7 @@ func (m Model) handleVolumeWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.wizard.volumeSize = size m.wizard.errorMsg = "" - m.wizard.step = VolumeWizardStepConfirm - m.wizard.volumeConfirmBtnIdx = 0 + m.wizard.step = VolumeWizardStepEncryption case tea.KeyBackspace: if len(m.wizard.volumeSizeInput) > 0 { m.wizard.volumeSizeInput = m.wizard.volumeSizeInput[:len(m.wizard.volumeSizeInput)-1] @@ -6592,7 +6690,7 @@ func (m Model) handleVolumeWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { m.mode = LoadingView return m, m.fetchDataForPath("/storage/block") case "left", "esc": - m.wizard.step = VolumeWizardStepSize + m.wizard.step = VolumeWizardStepEncryption } return m, nil } diff --git a/internal/services/browser/views/block_storage/detail.go b/internal/services/browser/views/block_storage/detail.go index 1eb1e977..2bcf7323 100644 --- a/internal/services/browser/views/block_storage/detail.go +++ b/internal/services/browser/views/block_storage/detail.go @@ -61,12 +61,20 @@ func (v *DetailView) Render(width, height int) string { size := getSizeStr(v.volume) bootable := getBootable(v.volume) + encryptionLabel := "Aucun" + encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + if strings.HasSuffix(vType, "-luks") { + encryptionLabel = "Actif" + encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + } + var infoContent strings.Builder infoContent.WriteString(views.RenderKeyValue("ID", id) + "\n") infoContent.WriteString(views.RenderKeyValue("Status", views.RenderStatus(status)) + "\n") infoContent.WriteString(views.RenderKeyValue("Region", region) + "\n") infoContent.WriteString(views.RenderKeyValue("Type", vType) + "\n") infoContent.WriteString(views.RenderKeyValue("Size", size+" GB") + "\n") + infoContent.WriteString(views.StyleLabel.Render("Encryption:") + " " + encryptionStyle.Render(encryptionLabel) + "\n") infoContent.WriteString(views.RenderKeyValue("Bootable", bootable) + "\n") if description != "" { infoContent.WriteString(views.RenderKeyValue("Description", description) + "\n") @@ -112,7 +120,7 @@ func (v *DetailView) renderActions() string { BorderForeground(lipgloss.Color("#00FF7F")). Padding(0, 1). Width(20) - return views.StyleStatusWarning.Render(fmt.Sprintf("New size in GB (current: %s):", currentSize)) + "\n" + + return views.StyleStatusWarning.Render(fmt.Sprintf("New size in GB (current: %s GB, must be greater):", currentSize)) + "\n" + inputStyle.Render(v.extendInput+"▌") + "\n\n" + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") } @@ -179,14 +187,24 @@ func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { v.extendInput = "" case tea.KeyEnter: if v.extendInput != "" { - size := v.extendInput + newSize := v.extendInput + currentSizeStr := getSizeStr(v.volume) + // Validate new size > current size + var newSizeInt, currentSizeInt int + fmt.Sscanf(newSize, "%d", &newSizeInt) + fmt.Sscanf(currentSizeStr, "%d", ¤tSizeInt) + if newSizeInt <= currentSizeInt { + // Invalid: don't submit, reset input + v.extendInput = "" + return nil + } v.extendMode = false v.extendInput = "" return func() tea.Msg { return ExecuteVolumeActionMsg{ Volume: v.volume, Action: VolumeActionExtend, - Param: size, + Param: newSize, } } } @@ -234,7 +252,7 @@ func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { v.renameInput = getString(v.volume, "name") v.renameMode = true case VolumeActionExtend: - v.extendInput = getSizeStr(v.volume) + v.extendInput = "" v.extendMode = true } return nil diff --git a/internal/services/browser/views/block_storage/table.go b/internal/services/browser/views/block_storage/table.go index 9653fce1..c892a304 100644 --- a/internal/services/browser/views/block_storage/table.go +++ b/internal/services/browser/views/block_storage/table.go @@ -7,6 +7,7 @@ package block_storage import ( + "encoding/json" "fmt" "strings" @@ -208,6 +209,10 @@ func getSizeStr(volume map[string]interface{}) string { return fmt.Sprintf("%d", int(v)) case int: return fmt.Sprintf("%d", v) + case json.Number: + if i, err := v.Int64(); err == nil { + return fmt.Sprintf("%d", i) + } } return "-" } From 5a2c66e52c1c777de9cc067507843f5499309e29 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 27 Apr 2026 13:13:42 +0000 Subject: [PATCH 05/55] feat(browser): added under storage nav Signed-off-by: olivier dubo --- internal/services/browser/api.go | 2 +- internal/services/browser/manager.go | 146 ++++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index b001dcf8..e0d0ed81 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1395,7 +1395,7 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createKubernetesTable(msg.data, m.width, m.height) case ProductInstances: m.table = createInstancesTable(msg.data, m.imageMap, m.floatingIPMap, m.width, m.height) - case ProductStorage: + case ProductStorageBlock: m.table = createBlockStorageTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 19d5a409..940a7776 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -108,7 +108,13 @@ const ( ProductKubernetes ProductManagedDatabases ProductManagedAnalytics - ProductStorage + ProductStorage // "Stockage" top-level nav + ProductStorageBlock // Block Storage (sous-nav) + ProductStorageFile // File Storage (sous-nav) + ProductStorageBackup // Volume Backup (sous-nav) + ProductStorageSnapshot // Volume Snapshot (sous-nav) + ProductStorageObject // Object Storage (sous-nav) + ProductStorageArchive // Cloud Archive (sous-nav) ProductNetworks ProductProjects ) @@ -263,6 +269,7 @@ type Model struct { previousMode ViewMode // Previous mode to return to from debug view currentProduct ProductType navIdx int // Index in navigation bar + storageSubIdx int // Index in storage sub-navigation (0=Prise en main, 1=Block Storage, ...) table table.Model detailData map[string]interface{} currentData []map[string]interface{} @@ -589,11 +596,28 @@ func getNavItems() []NavItem { {Label: " Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, - {Label: "Block Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, + {Label: "Stockage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, {Label: "Private networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, } } +type StorageSubItem struct { + Label string + Product ProductType + Path string + Enabled bool +} + +func getStorageSubItems() []StorageSubItem { + return []StorageSubItem{ + {Label: "Block Storage", Product: ProductStorageBlock, Path: "/storage/block", Enabled: true}, + {Label: "File Storage", Product: ProductStorageFile, Path: "/storage/file", Enabled: false}, + {Label: "Volume Backup", Product: ProductStorageBackup, Path: "/storage/backup", Enabled: false}, + {Label: "Volume Snapshot", Product: ProductStorageSnapshot, Path: "/storage/snapshot", Enabled: false}, + {Label: "Object Storage", Product: ProductStorageObject, Path: "/storage/object", Enabled: false}, + } +} + // StartBrowser is the entry point for the browser TUI func StartBrowser(cmd *cobra.Command, args []string) { // Reset creation command @@ -717,7 +741,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Loading Kubernetes regions...", } return m, m.fetchKubeRegions() - } else if msg.product == ProductStorage { + } else if msg.product == ProductStorageBlock { m.mode = WizardView m.wizard = WizardData{ step: VolumeWizardStepName, @@ -881,7 +905,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleExecuteVolumeAction(msg) case views.GoBackMsg: - if m.mode == DetailView && m.currentProduct == ProductStorage { + if m.mode == DetailView && m.currentProduct == ProductStorageBlock { m.volumeDetailView = nil m.mode = TableView return m, nil @@ -1041,7 +1065,57 @@ func (m Model) renderNavBar(width int) string { } navContent := lipgloss.JoinHorizontal(lipgloss.Top, items...) - return navBarStyle.Width(width - 2).Render(navContent) + mainNav := navBarStyle.Width(width - 2).Render(navContent) + + // If on Stockage, render the sub-navigation below + if navItems[m.navIdx].Product == ProductStorage { + subNav := m.renderStorageSubNav(width) + return mainNav + "\n" + subNav + } + + return mainNav +} + +func (m Model) renderStorageSubNav(width int) string { + subItems := getStorageSubItems() + var items []string + + subItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Padding(0, 2) + subItemSelectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF7F")). + Bold(true). + Padding(0, 2). + Underline(true) + subItemDisabledStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444444")). + Padding(0, 2) + + for i, item := range subItems { + var style lipgloss.Style + if i == m.storageSubIdx { + style = subItemSelectedStyle + } else if !item.Enabled { + style = subItemDisabledStyle + } else { + style = subItemStyle + } + label := item.Label + // if !item.Enabled && i != m.storageSubIdx { + // label += " (bientôt)" + // } + items = append(items, style.Render(label)) + } + + subBarStyle := lipgloss.NewStyle(). + Padding(0, 1). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#333333")) + tabHintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")).Padding(0, 2) +subContent := lipgloss.JoinHorizontal(lipgloss.Top, append(items, tabHintStyle.Render(""))...) + return subBarStyle.Width(width - 2).Render(subContent) } func (m Model) renderContentBox(width int) string { @@ -3645,7 +3719,7 @@ func (m Model) getProductCreationInfo() (string, string) { return "databases", fmt.Sprintf("ovhcloud cloud managed-database create --cloud-project %s", m.cloudProject) case ProductManagedAnalytics: return "analytics", fmt.Sprintf("ovhcloud cloud managed-analytics create --cloud-project %s", m.cloudProject) - case ProductStorage: + case ProductStorageBlock: return "block storage volumes", "" case ProductNetworks: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) @@ -3729,7 +3803,7 @@ func (m Model) renderDetailView(width int) string { return m.renderKubernetesDetail(width) case ProductProjects: return m.renderProjectDetail(width) - case ProductStorage: + case ProductStorageBlock: if m.volumeDetailView != nil { return m.volumeDetailView.Render(width, 0) } @@ -4139,11 +4213,17 @@ func (m Model) renderFooter() string { case TableView: if m.filterInput != "" { help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" + } else if m.currentProduct == ProductStorageBlock { + help = "Tab/Shift+Tab: Sous-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" } else { help = "←→: Switch Product • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: - help = "←→: Switch Product • c: Create • d: Debug • p: Change Project • q: Quit" + if m.currentProduct == ProductStorageBlock { + help = "Tab/Shift+Tab: Sous-menu • c: Create • d: Debug • p: Change Project • q: Quit" + } else { + help = "←→: Switch Product • c: Create • d: Debug • p: Change Project • q: Quit" + } case DetailView: if m.actionConfirm { help = "Enter: Confirm Action • Esc: Cancel" @@ -4191,7 +4271,7 @@ func (m Model) renderFooter() string { case KubeKubeconfigPickerView: help = "↑↓: Navigate • Enter: Open/Select • Esc: Cancel" case ComingSoonView: - help = "←→: Switch Product • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • tab/shift+tab: Navigate Sub-menu • d: Debug • p: Change Project • q: Quit" default: help = "Enter: Select • q: Quit" } @@ -4261,12 +4341,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleKubeKubeconfigPickerKeyPress(msg) } - // Delegate to block storage detail view when in DetailView for ProductStorage - if m.mode == DetailView && m.currentProduct == ProductStorage && m.volumeDetailView != nil { + // Delegate to block storage detail view when in DetailView for ProductStorageBlock + if m.mode == DetailView && m.currentProduct == ProductStorageBlock && m.volumeDetailView != nil { cmd := m.volumeDetailView.HandleKey(msg) return m, cmd } + navItems := getNavItems() switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -4355,6 +4436,24 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "tab": + // Navigate storage sub-nav forward (with wrap-around) + if m.mode != DetailView && (m.currentProduct == ProductStorageBlock || navItems[m.navIdx].Product == ProductStorage) { + subItems := getStorageSubItems() + m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) + return m.loadStorageSubProduct() + } + return m, nil + + case "shift+tab": + // Navigate storage sub-nav backward (with wrap-around) + if m.mode != DetailView && (m.currentProduct == ProductStorageBlock || navItems[m.navIdx].Product == ProductStorage) { + subItems := getStorageSubItems() + m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) + return m.loadStorageSubProduct() + } + return m, nil + case "esc": // Clear filter in TableView if active if m.mode == TableView && m.filterInput != "" { @@ -4487,7 +4586,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = DetailView // If viewing a block storage volume, init the detail view - if m.currentProduct == ProductStorage { + if m.currentProduct == ProductStorageBlock { ctx := &views.Context{Width: m.width, Height: m.height} m.volumeDetailView = block_storage.NewDetailView(ctx, m.detailData) return m, nil @@ -6269,6 +6368,12 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { return m, nil } + // For Stockage, go to default sub-item (Block Storage = index 1) + if currentNav.Product == ProductStorage { + m.storageSubIdx = 1 + return m.loadStorageSubProduct() + } + m.mode = LoadingView // For instances and Kubernetes, start the auto-refresh timer @@ -6281,6 +6386,23 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { return m, m.fetchDataForPath(currentNav.Path) } +func (m Model) loadStorageSubProduct() (Model, tea.Cmd) { + subItems := getStorageSubItems() + sub := subItems[m.storageSubIdx] + m.currentProduct = sub.Product + m.detailData = nil + m.currentData = nil + m.volumeDetailView = nil + + if !sub.Enabled { + m.mode = ComingSoonView + return m, nil + } + + m.mode = LoadingView + return m, m.fetchDataForPath(sub.Path) +} + // Helper functions func getStringValue(data map[string]interface{}, key string, defaultVal string) string { if val, ok := data[key]; ok { From 150ec7ad90bf873db4d21499cb608be17223a79c Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 27 Apr 2026 13:35:21 +0000 Subject: [PATCH 06/55] feat(browser): fixed navigation tab to arrow when you are in submenu Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 90 +++++++++++++++++----------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 940a7776..52090719 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -268,8 +268,9 @@ type Model struct { mode ViewMode previousMode ViewMode // Previous mode to return to from debug view currentProduct ProductType - navIdx int // Index in navigation bar - storageSubIdx int // Index in storage sub-navigation (0=Prise en main, 1=Block Storage, ...) + navIdx int // Index in navigation bar + storageSubIdx int // Index in storage sub-navigation (0=Prise en main, 1=Block Storage, ...) + inStorageSubNav bool // Whether the keyboard focus is in the storage sub-nav bar table table.Model detailData map[string]interface{} currentData []map[string]interface{} @@ -1083,38 +1084,40 @@ func (m Model) renderStorageSubNav(width int) string { subItemStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). Padding(0, 2) - subItemSelectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00FF7F")). - Bold(true). - Padding(0, 2). - Underline(true) + // subItemSelectedStyle := lipgloss.NewStyle(). + // Foreground(lipgloss.Color("#00FF7F")). + // Bold(true). + // Padding(0, 2). + // Underline(true) subItemDisabledStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#444444")). Padding(0, 2) for i, item := range subItems { var style lipgloss.Style - if i == m.storageSubIdx { - style = subItemSelectedStyle + if i == m.storageSubIdx && m.inStorageSubNav { + // style = subItemSelectedStyle + } else if i == m.storageSubIdx { + // active item but focus is in main nav — show dimmed selection + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) } else if !item.Enabled { style = subItemDisabledStyle } else { style = subItemStyle } - label := item.Label - // if !item.Enabled && i != m.storageSubIdx { - // label += " (bientôt)" - // } - items = append(items, style.Render(label)) + items = append(items, style.Render(item.Label)) } + borderColor := lipgloss.Color("#333333") + if m.inStorageSubNav { + borderColor = lipgloss.Color("#00FF7F") + } subBarStyle := lipgloss.NewStyle(). Padding(0, 1). BorderTop(true). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#333333")) - tabHintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")).Padding(0, 2) -subContent := lipgloss.JoinHorizontal(lipgloss.Top, append(items, tabHintStyle.Render(""))...) + BorderForeground(borderColor) + subContent := lipgloss.JoinHorizontal(lipgloss.Top, items...) return subBarStyle.Width(width - 2).Render(subContent) } @@ -4213,14 +4216,18 @@ func (m Model) renderFooter() string { case TableView: if m.filterInput != "" { help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" + } else if m.inStorageSubNav { + help = "←→: Sub-menu • ↑: Back to main nav • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" } else if m.currentProduct == ProductStorageBlock { - help = "Tab/Shift+Tab: Sous-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • ↓: Enter Sub-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" } else { help = "←→: Switch Product • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: - if m.currentProduct == ProductStorageBlock { - help = "Tab/Shift+Tab: Sous-menu • c: Create • d: Debug • p: Change Project • q: Quit" + if m.inStorageSubNav { + help = "←→: Sub-menu • ↑: Back to main nav • c: Create • d: Debug • p: Change Project • q: Quit" + } else if m.currentProduct == ProductStorageBlock { + help = "←→: Switch Product • ↓: Enter Sub-menu • c: Create • d: Debug • p: Change Project • q: Quit" } else { help = "←→: Switch Product • c: Create • d: Debug • p: Change Project • q: Quit" } @@ -4376,9 +4383,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In storage sub-nav + if m.inStorageSubNav && m.mode != DetailView { + subItems := getStorageSubItems() + m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) + return m.loadStorageSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { if m.navIdx > 0 { m.navIdx-- + m.inStorageSubNav = false return m.loadCurrentProduct() } } @@ -4409,10 +4423,17 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In storage sub-nav + if m.inStorageSubNav && m.mode != DetailView { + subItems := getStorageSubItems() + m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) + return m.loadStorageSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { navItems := getNavItems() if m.navIdx < len(navItems)-1 { m.navIdx++ + m.inStorageSubNav = false return m.loadCurrentProduct() } } @@ -4437,21 +4458,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "tab": - // Navigate storage sub-nav forward (with wrap-around) - if m.mode != DetailView && (m.currentProduct == ProductStorageBlock || navItems[m.navIdx].Product == ProductStorage) { - subItems := getStorageSubItems() - m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) - return m.loadStorageSubProduct() - } return m, nil case "shift+tab": - // Navigate storage sub-nav backward (with wrap-around) - if m.mode != DetailView && (m.currentProduct == ProductStorageBlock || navItems[m.navIdx].Product == ProductStorage) { - subItems := getStorageSubItems() - m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) - return m.loadStorageSubProduct() - } return m, nil case "esc": @@ -4603,16 +4612,28 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "up", "down", "j", "k": + key := msg.String() + // ↓ on main nav over Stockage → enter sub-nav + if (key == "down" || key == "j") && !m.inStorageSubNav && m.mode != DetailView && + m.mode != ProjectSelectView && navItems[m.navIdx].Product == ProductStorage { + m.inStorageSubNav = true + return m.loadStorageSubProduct() + } + // ↑ in sub-nav → exit sub-nav, back to main nav + if (key == "up" || key == "k") && m.inStorageSubNav && m.mode != DetailView { + m.inStorageSubNav = false + return m, nil + } // Node pools list navigation if m.mode == NodePoolsView { clusterId := getStringValue(m.detailData, "id", "") nodePools := m.kubeNodePools[clusterId] if len(nodePools) > 0 { - if msg.String() == "down" || msg.String() == "j" { + if key == "down" || key == "j" { if m.nodePoolsSelectedIdx < len(nodePools)-1 { m.nodePoolsSelectedIdx++ } - } else if msg.String() == "up" || msg.String() == "k" { + } else if key == "up" || key == "k" { if m.nodePoolsSelectedIdx > 0 { m.nodePoolsSelectedIdx-- } @@ -6361,6 +6382,7 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { m.currentProduct = currentNav.Product m.detailData = nil m.currentData = nil + m.inStorageSubNav = false // Show coming soon view for unimplemented products if currentNav.Product == ProductNetworks { From 5c1a9bf4fc542e092d606a87b034bc4d79424da5 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 27 Apr 2026 14:42:43 +0000 Subject: [PATCH 07/55] feat(browser): added file storage Signed-off-by: olivier dubo --- internal/services/browser/api.go | 323 ++++++++++++++ internal/services/browser/manager.go | 633 ++++++++++++++++++++++++++- 2 files changed, 938 insertions(+), 18 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index e0d0ed81..427ec0a4 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -82,6 +82,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/storage/file": + return func() tea.Msg { + msg := m.fetchFileStorageData() + msg.forProduct = product + return msg + } case "/networks/private": return func() tea.Msg { msg := m.fetchPrivateNetworksData() @@ -1397,6 +1403,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createInstancesTable(msg.data, m.imageMap, m.floatingIPMap, m.width, m.height) case ProductStorageBlock: m.table = createBlockStorageTable(msg.data, m.width, m.height) + case ProductStorageFile: + m.table = createFileStorageTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -4028,3 +4036,318 @@ func (m Model) handleNodePoolDeleted(msg nodePoolDeletedMsg) (tea.Model, tea.Cmd clusterId := getString(m.detailData, "id") return m, m.fetchKubeNodePools(clusterId) } + +// ─── File Storage (NFS Share) API ──────────────────────────────────────────── + +// fetchFileShareRegions probes each region concurrently for file storage support +// and returns only regions that have the share API available. +func (m Model) fetchFileShareRegions() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + type probeResult struct { + region string + supported bool + } + ch := make(chan probeResult, len(regionNames)) + for _, name := range regionNames { + go func(regionName string) { + probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(regionName)) + var result []map[string]interface{} + err := httpLib.Client.Get(probe, &result) + ch <- probeResult{region: regionName, supported: err == nil} + }(name) + } + + var supported []string + for range regionNames { + r := <-ch + if r.supported { + supported = append(supported, r.region) + } + } + sort.Strings(supported) + + if len(supported) == 0 { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("no regions support file storage in this project")} + } + return fileShareRegionsLoadedMsg{regions: supported} + } +} + +// fetchFileShareNetworks fetches available private networks for file share creation +func (m Model) fetchFileShareNetworks() tea.Cmd { + region := m.wizard.selectedRegion + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareNetworksLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var networks []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) + if err := httpLib.Client.Get(endpoint, &networks); err != nil { + return fileShareNetworksLoadedMsg{err: fmt.Errorf("failed to fetch networks: %w", err)} + } + // Pre-extract the OpenStack UUID for our target region into a synthetic field + // so the UI doesn't need to traverse the regions array. + for i, net := range networks { + openstackId := "" + if regions, ok := net["regions"].([]interface{}); ok { + for _, r := range regions { + if rm, ok := r.(map[string]interface{}); ok { + if rm["region"] == region { + openstackId, _ = rm["openstackId"].(string) + break + } + } + } + } + // Only include networks that are active in the target region + if openstackId != "" { + networks[i]["_openstackId"] = openstackId + } + } + // Filter to networks that have the region active + var filtered []map[string]interface{} + for _, net := range networks { + if _, ok := net["_openstackId"]; ok { + filtered = append(filtered, net) + } + } + sort.Slice(filtered, func(i, j int) bool { + iName, _ := filtered[i]["name"].(string) + jName, _ := filtered[j]["name"].(string) + return iName < jName + }) + return fileShareNetworksLoadedMsg{networks: filtered} + } +} + +// fetchFileShareSubnets fetches available subnets for a specific private network +func (m Model) fetchFileShareSubnets(networkID string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareSubnetsLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var subnets []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", m.cloudProject, networkID) + if err := httpLib.Client.Get(endpoint, &subnets); err != nil { + return fileShareSubnetsLoadedMsg{err: fmt.Errorf("failed to fetch subnets: %w", err)} + } + sort.Slice(subnets, func(i, j int) bool { + iCIDR, _ := subnets[i]["cidr"].(string) + jCIDR, _ := subnets[j]["cidr"].(string) + return iCIDR < jCIDR + }) + return fileShareSubnetsLoadedMsg{subnets: subnets} + } +} + +// createFileShare creates a new NFS file share +func (m Model) createFileShare() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + body := map[string]interface{}{ + "name": m.wizard.fileShareName, + "type": m.wizard.fileShareType, + "size": m.wizard.fileShareSize, + "networkId": m.wizard.fileShareNetworkId, + "subnetId": m.wizard.fileShareSubnetId, + } + var share map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(m.wizard.selectedRegion)) + if err := httpLib.Client.Post(endpoint, body, &share); err != nil { + return fileShareCreatedMsg{err: fmt.Errorf("failed to create file share: %w", err)} + } + return fileShareCreatedMsg{share: share} + } +} + +// fetchFileStorageData fetches the list of NFS file shares across all supported regions +func (m Model) fetchFileStorageData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + return dataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + type regionResult struct { + shares []map[string]interface{} + } + ch := make(chan regionResult, len(regionNames)) + for _, name := range regionNames { + go func(regionName string) { + probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(regionName)) + var shares []map[string]interface{} + if err := httpLib.Client.Get(probe, &shares); err != nil { + ch <- regionResult{} + return + } + // Tag each share with its region + for i := range shares { + shares[i]["region"] = regionName + } + ch <- regionResult{shares: shares} + }(name) + } + + var allShares []map[string]interface{} + for range regionNames { + r := <-ch + allShares = append(allShares, r.shares...) + } + + // Sort by creation date or name + sort.Slice(allShares, func(i, j int) bool { + iName, _ := allShares[i]["name"].(string) + jName, _ := allShares[j]["name"].(string) + return iName < jName + }) + + return dataLoadedMsg{data: allShares} +} + +// createFileStorageTable creates a table rendering file storage shares +func createFileStorageTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 25}, + {Title: "ID", Width: 20}, + {Title: "Région", Width: 12}, + {Title: "Type", Width: 16}, + {Title: "Capacité", Width: 12}, + {Title: "Statut", Width: 12}, + } + + var rows []table.Row + for _, share := range data { + name := getString(share, "name") + id := getString(share, "id") + region := getString(share, "region") + shareType := getString(share, "type") + status := getString(share, "status") + + // Size + sizeStr := "-" + if sz, ok := share["size"]; ok { + switch v := sz.(type) { + case float64: + sizeStr = fmt.Sprintf("%d GB", int(v)) + case json.Number: + if f, err := v.Float64(); err == nil { + sizeStr = fmt.Sprintf("%d GB", int(f)) + } + case int: + sizeStr = fmt.Sprintf("%d GB", v) + } + } + + rows = append(rows, table.Row{name, id, region, shareType, sizeStr, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + return t +} + +// ─── File Storage wizard message handlers ──────────────────────────────────── + +func (m Model) handleFileShareRegionsLoaded(msg fileShareRegionsLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareRegions = msg.regions + if len(msg.regions) == 0 { + m.wizard.errorMsg = "No regions support file storage in this project" + return m, nil + } + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareNetworksLoaded(msg fileShareNetworksLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareNetworks = msg.networks + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareSubnetsLoaded(msg fileShareSubnetsLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareSubnets = msg.subnets + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareCreated(msg fileShareCreatedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + name := getString(msg.share, "name") + if name == "" { + name = "file share" + } + m.notification = fmt.Sprintf("✅ File share '%s' created successfully!", name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.wizard = WizardData{} + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/file"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 52090719..82c17163 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -100,6 +100,16 @@ const ( VolumeWizardStepConfirm ) +const ( + // File Storage wizard steps (offset by 400) + FileWizardStepName WizardStep = iota + 400 + FileWizardStepRegion + FileWizardStepType + FileWizardStepSize + FileWizardStepNetwork + FileWizardStepConfirm +) + // ProductType represents a product category type ProductType int @@ -259,6 +269,22 @@ type WizardData struct { volumeAvailabilityZone string // Selected availability zone volumeEncryptionIdx int // 0=none, 1=OVHcloud Managed Key volumeConfirmBtnIdx int // 0 = Create, 1 = Cancel + // File Storage wizard fields + fileShareName string + fileShareNameInput string + fileShareSize int + fileShareSizeInput string + fileShareType string // selected type (e.g., "standard-1az") + fileShareTypeIdx int + fileShareRegions []string + fileShareNetworks []map[string]interface{} + fileShareSubnets []map[string]interface{} + fileShareNetworkId string + fileShareNetworkName string + fileShareSubnetId string + fileShareSubnetCIDR string + fileShareNetworkMenuIdx int // 0=network list, 1=subnet list + fileShareConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -590,6 +616,26 @@ type volumeActionDoneMsg struct { type refreshBlockStorageMsg struct{} +type fileShareRegionsLoadedMsg struct { + regions []string + err error +} + +type fileShareNetworksLoadedMsg struct { + networks []map[string]interface{} + err error +} + +type fileShareSubnetsLoadedMsg struct { + subnets []map[string]interface{} + err error +} + +type fileShareCreatedMsg struct { + share map[string]interface{} + err error +} + // Navigation items for products (shown after project is selected) func getNavItems() []NavItem { return []NavItem{ @@ -612,7 +658,7 @@ type StorageSubItem struct { func getStorageSubItems() []StorageSubItem { return []StorageSubItem{ {Label: "Block Storage", Product: ProductStorageBlock, Path: "/storage/block", Enabled: true}, - {Label: "File Storage", Product: ProductStorageFile, Path: "/storage/file", Enabled: false}, + {Label: "File Storage", Product: ProductStorageFile, Path: "/storage/file", Enabled: true}, {Label: "Volume Backup", Product: ProductStorageBackup, Path: "/storage/backup", Enabled: false}, {Label: "Volume Snapshot", Product: ProductStorageSnapshot, Path: "/storage/snapshot", Enabled: false}, {Label: "Object Storage", Product: ProductStorageObject, Path: "/storage/object", Enabled: false}, @@ -748,6 +794,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { step: VolumeWizardStepName, } return m, nil + } else if msg.product == ProductStorageFile { + m.mode = WizardView + m.wizard = WizardData{ + step: FileWizardStepName, + isLoading: true, + loadingMessage: "Loading available regions...", + } + return m, m.fetchFileShareRegions() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -919,6 +973,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case refreshBlockStorageMsg: return m, m.fetchDataForPath("/storage/block") + case fileShareRegionsLoadedMsg: + return m.handleFileShareRegionsLoaded(msg) + + case fileShareNetworksLoadedMsg: + return m.handleFileShareNetworksLoaded(msg) + + case fileShareSubnetsLoadedMsg: + return m.handleFileShareSubnetsLoaded(msg) + + case fileShareCreatedMsg: + return m.handleFileShareCreated(msg) + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -1068,8 +1134,10 @@ func (m Model) renderNavBar(width int) string { navContent := lipgloss.JoinHorizontal(lipgloss.Top, items...) mainNav := navBarStyle.Width(width - 2).Render(navContent) - // If on Stockage, render the sub-navigation below - if navItems[m.navIdx].Product == ProductStorage { + // Show storage sub-navigation when on Stockage or any storage sub-product + isStorageContext := navItems[m.navIdx].Product == ProductStorage || + (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive) + if isStorageContext { subNav := m.renderStorageSubNav(width) return mainNav + "\n" + subNav } @@ -1093,12 +1161,23 @@ func (m Model) renderStorageSubNav(width int) string { Foreground(lipgloss.Color("#444444")). Padding(0, 2) + // Determine the active sub-item from currentProduct so it stays correct + // even when storageSubIdx gets out of sync. + activeSubIdx := m.storageSubIdx + for i, item := range subItems { + if item.Product == m.currentProduct { + activeSubIdx = i + break + } + } + for i, item := range subItems { var style lipgloss.Style - if i == m.storageSubIdx && m.inStorageSubNav { - // style = subItemSelectedStyle - } else if i == m.storageSubIdx { - // active item but focus is in main nav — show dimmed selection + if i == activeSubIdx && m.inStorageSubNav { + // Focused selection: bright green + bold + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) + } else if i == activeSubIdx { + // Active item, focus is on main nav — dimmed green style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) } else if !item.Enabled { style = subItemDisabledStyle @@ -1127,7 +1206,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 300 { + if m.wizard.step >= 400 { + // File Storage wizard + titleText = " 🗂️ Create File Share " + } else if m.wizard.step >= 300 { // Volume wizard titleText = " 💾 Create Volume " } else if m.wizard.step >= 200 { @@ -2035,7 +2117,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 300 { + if m.wizard.step >= 400 { + // File Storage wizard + steps = append(steps, "Name", "Region", "Type", "Size", "Network", "Confirm") + stepMapping = append(stepMapping, FileWizardStepName, FileWizardStepRegion, FileWizardStepType, FileWizardStepSize, FileWizardStepNetwork, FileWizardStepConfirm) + } else if m.wizard.step >= 300 { // Volume wizard steps = append(steps, "Name", "Region", "Type", "Avail. Zone", "Size", "Encryption", "Confirm") stepMapping = append(stepMapping, VolumeWizardStepName, VolumeWizardStepRegion, VolumeWizardStepType, VolumeWizardStepAvailabilityZone, VolumeWizardStepSize, VolumeWizardStepEncryption, VolumeWizardStepConfirm) @@ -2164,6 +2250,19 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderVolumeWizardEncryptionStep(width)) case VolumeWizardStepConfirm: content.WriteString(m.renderVolumeWizardConfirmStep(width)) + // File Storage wizard steps + case FileWizardStepName: + content.WriteString(m.renderFileWizardNameStep(width)) + case FileWizardStepRegion: + content.WriteString(m.renderFileWizardRegionStep(width)) + case FileWizardStepType: + content.WriteString(m.renderFileWizardTypeStep(width)) + case FileWizardStepSize: + content.WriteString(m.renderFileWizardSizeStep(width)) + case FileWizardStepNetwork: + content.WriteString(m.renderFileWizardNetworkStep(width)) + case FileWizardStepConfirm: + content.WriteString(m.renderFileWizardConfirmStep(width)) } return content.String() @@ -3724,6 +3823,8 @@ func (m Model) getProductCreationInfo() (string, string) { return "analytics", fmt.Sprintf("ovhcloud cloud managed-analytics create --cloud-project %s", m.cloudProject) case ProductStorageBlock: return "block storage volumes", "" + case ProductStorageFile: + return "file shares", "" case ProductNetworks: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) default: @@ -4268,6 +4369,18 @@ func (m Model) renderFooter() string { help = "↑↓: Select • Enter: Continue • ←: Back • Esc: Cancel" } else if m.wizard.step == VolumeWizardStepConfirm { help = "←→: Select • Enter: Confirm • Esc: Cancel" + } else if m.wizard.step == FileWizardStepName { + help = "Type name • Enter: Continue • Esc: Cancel" + } else if m.wizard.step == FileWizardStepRegion { + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" + } else if m.wizard.step == FileWizardStepType { + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" + } else if m.wizard.step == FileWizardStepSize { + help = "Type size in GB • Enter: Continue • ←: Back • Esc: Cancel" + } else if m.wizard.step == FileWizardStepNetwork { + help = "↑↓: Navigate • Enter: Select/Expand • ←: Back • Esc: Cancel" + } else if m.wizard.step == FileWizardStepConfirm { + help = "←→: Select • Enter: Confirm • Esc: Cancel" } else { help = "↑↓: Navigate • d: Debug • Enter: Select • ←: Back • Esc: Cancel" } @@ -4383,10 +4496,19 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav - if m.inStorageSubNav && m.mode != DetailView { + // In storage sub-nav (either focused or when on a storage sub-product) + isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive + if (m.inStorageSubNav || isStorageSubProduct) && m.mode != DetailView { subItems := getStorageSubItems() + // Find current index from product + for i, item := range subItems { + if item.Product == m.currentProduct { + m.storageSubIdx = i + break + } + } m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) + m.inStorageSubNav = true return m.loadStorageSubProduct() } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { @@ -4423,10 +4545,19 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav - if m.inStorageSubNav && m.mode != DetailView { + // In storage sub-nav (either focused or when on a storage sub-product) + isStorageSubProduct2 := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive + if (m.inStorageSubNav || isStorageSubProduct2) && m.mode != DetailView { subItems := getStorageSubItems() + // Find current index from product + for i, item := range subItems { + if item.Product == m.currentProduct { + m.storageSubIdx = i + break + } + } m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) + m.inStorageSubNav = true return m.loadStorageSubProduct() } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { @@ -4942,13 +5073,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -4966,7 +5097,9 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 300 { + if m.wizard.step >= 400 { + returnPath = "/storage/file" + } else if m.wizard.step >= 300 { // Volume wizard returnPath = "/storage/block" } else if m.wizard.step >= 100 { @@ -5038,6 +5171,19 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleVolumeWizardEncryptionKeys(key) case VolumeWizardStepConfirm: return m.handleVolumeWizardConfirmKeys(key) + // File Storage wizard steps + case FileWizardStepName: + return m.handleFileWizardNameKeys(msg) + case FileWizardStepRegion: + return m.handleFileWizardRegionKeys(key, msg) + case FileWizardStepType: + return m.handleFileWizardTypeKeys(key, msg) + case FileWizardStepSize: + return m.handleFileWizardSizeKeys(msg) + case FileWizardStepNetwork: + return m.handleFileWizardNetworkKeys(key, msg) + case FileWizardStepConfirm: + return m.handleFileWizardConfirmKeys(key) } return m, nil @@ -6390,9 +6536,9 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { return m, nil } - // For Stockage, go to default sub-item (Block Storage = index 1) + // For Stockage, go to default sub-item (Block Storage = index 0) if currentNav.Product == ProductStorage { - m.storageSubIdx = 1 + m.storageSubIdx = 0 return m.loadStorageSubProduct() } @@ -7162,3 +7308,454 @@ func (m Model) handleNodePoolDeleteConfirmKeyPress(msg tea.KeyMsg) (tea.Model, t return m, nil } } + +// ─── File Storage wizard render functions ───────────────────────────────────── + +func (m Model) renderFileWizardNameStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Enter a name for the file share:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + content.WriteString(inputStyle.Render(m.wizard.fileShareNameInput+"▌") + "\n\n") + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Type to enter • Enter: Continue • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardRegionStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select region for the file share:") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("Loading available regions...")) + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, region := range m.wizard.fileShareRegions { + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + region)) + } else { + content.WriteString(listStyle.Render(" " + region)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardTypeStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select performance mode:") + "\n\n") + + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + content.WriteString(descStyle.Render("Choose the performance tier for the NFS share.") + "\n\n") + + types := []string{"standard-1az"} + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, t := range types { + if i == m.wizard.fileShareTypeIdx { + content.WriteString(selectedStyle.Render("▶ " + t)) + } else { + content.WriteString(listStyle.Render(" " + t)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardSizeStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Enter file share size (GB):") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(20) + content.WriteString(inputStyle.Render(m.wizard.fileShareSizeInput+"▌") + "\n\n") + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Type size in GB • Enter: Continue • ←: Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardNetworkStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + + if m.wizard.isLoading { + if m.wizard.fileShareNetworkMenuIdx == 0 { + content.WriteString(titleStyle.Render("Select private network:") + "\n\n") + content.WriteString(loadingStyle.Render("Loading networks...")) + } else { + content.WriteString(titleStyle.Render("Select subnet:") + "\n\n") + content.WriteString(loadingStyle.Render("Loading subnets...")) + } + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + if m.wizard.fileShareNetworkMenuIdx == 0 { + // Show network list + content.WriteString(titleStyle.Render("Select private network for the file share:") + "\n\n") + for i, network := range m.wizard.fileShareNetworks { + name, _ := network["name"].(string) + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + name)) + } else { + content.WriteString(listStyle.Render(" " + name)) + } + content.WriteString("\n") + } + if len(m.wizard.fileShareNetworks) == 0 { + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + content.WriteString(dimStyle.Render(" No private networks available in this region.") + "\n") + } + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + } else { + // Show subnet list + content.WriteString(titleStyle.Render(fmt.Sprintf("Select subnet (network: %s):", m.wizard.fileShareNetworkName)) + "\n\n") + for i, subnet := range m.wizard.fileShareSubnets { + cidr, _ := subnet["cidr"].(string) + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + cidr)) + } else { + content.WriteString(listStyle.Render(" " + cidr)) + } + content.WriteString("\n") + } + if len(m.wizard.fileShareSubnets) == 0 { + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + content.WriteString(dimStyle.Render(" No subnets available for this network.") + "\n") + } + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back to networks • Esc: Cancel")) + } + return content.String() +} + +func (m Model) renderFileWizardConfirmStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Confirm file share creation:") + "\n\n") + + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.fileShareName) + "\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.fileShareRegions[m.wizard.selectedIndex]) + "\n") + + // Actually selectedIndex won't be meaningful here; store the selected region + regionStr := m.wizard.fileShareRegions[0] + if len(m.wizard.fileShareRegions) > 0 { + // We stored the region in selectedRegion when we moved past the region step + regionStr = m.wizard.selectedRegion + } + content = strings.Builder{} + content.WriteString(titleStyle.Render("Confirm file share creation:") + "\n\n") + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.fileShareName) + "\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(regionStr) + "\n") + content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(m.wizard.fileShareType) + "\n") + content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.fileShareSize)) + "\n") + content.WriteString(labelStyle.Render(" Network:") + valueStyle.Render(m.wizard.fileShareNetworkName) + "\n") + content.WriteString(labelStyle.Render(" Subnet:") + valueStyle.Render(m.wizard.fileShareSubnetCIDR) + "\n") + + content.WriteString("\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render(m.wizard.loadingMessage)) + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + createStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + if m.wizard.fileShareConfirmBtnIdx == 0 { + content.WriteString(createStyle.Render(" ▶ [Create File Share]") + " ") + content.WriteString(dimStyle.Render("[Cancel]") + "\n") + } else { + content.WriteString(dimStyle.Render(" [Create File Share]") + " ") + content.WriteString(cancelStyle.Render("▶ [Cancel]") + "\n") + } + + return content.String() +} + +// ─── File Storage wizard key handler functions ──────────────────────────────── + +func (m Model) handleFileWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + name := strings.TrimSpace(m.wizard.fileShareNameInput) + if name == "" { + m.wizard.errorMsg = "File share name cannot be empty" + return m, nil + } + m.wizard.fileShareName = name + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepRegion + m.wizard.selectedIndex = 0 + if len(m.wizard.fileShareRegions) > 0 { + // Regions already loaded + return m, nil + } + // Should not happen (we load regions at wizard start) but just in case + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading available regions..." + return m, m.fetchFileShareRegions() + case tea.KeyBackspace: + if len(m.wizard.fileShareNameInput) > 0 { + m.wizard.fileShareNameInput = m.wizard.fileShareNameInput[:len(m.wizard.fileShareNameInput)-1] + } + case tea.KeyRunes: + m.wizard.fileShareNameInput += string(msg.Runes) + } + return m, nil +} + +func (m Model) handleFileWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + regions := m.wizard.fileShareRegions + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(regions)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(regions) == 0 { + return m, nil + } + m.wizard.selectedRegion = regions[m.wizard.selectedIndex] + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepType + m.wizard.fileShareTypeIdx = 0 + m.wizard.fileShareType = "standard-1az" + case "left": + m.wizard.step = FileWizardStepName + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleFileWizardTypeKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + types := []string{"standard-1az"} + switch key { + case "up", "k": + if m.wizard.fileShareTypeIdx > 0 { + m.wizard.fileShareTypeIdx-- + } + case "down", "j": + if m.wizard.fileShareTypeIdx < len(types)-1 { + m.wizard.fileShareTypeIdx++ + } + case "enter": + m.wizard.fileShareType = types[m.wizard.fileShareTypeIdx] + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepSize + if m.wizard.fileShareSize > 0 { + m.wizard.fileShareSizeInput = fmt.Sprintf("%d", m.wizard.fileShareSize) + } else { + m.wizard.fileShareSizeInput = "" + } + case "left": + m.wizard.step = FileWizardStepRegion + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleFileWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + sizeStr := strings.TrimSpace(m.wizard.fileShareSizeInput) + size, err := strconv.Atoi(sizeStr) + if err != nil || size < 1 { + m.wizard.errorMsg = "Size must be a positive integer (GB)" + return m, nil + } + m.wizard.fileShareSize = size + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepNetwork + m.wizard.fileShareNetworkMenuIdx = 0 + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading private networks..." + return m, m.fetchFileShareNetworks() + case tea.KeyBackspace: + if len(m.wizard.fileShareSizeInput) > 0 { + m.wizard.fileShareSizeInput = m.wizard.fileShareSizeInput[:len(m.wizard.fileShareSizeInput)-1] + } + case tea.KeyLeft: + m.wizard.step = FileWizardStepType + m.wizard.fileShareTypeIdx = 0 + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + m.wizard.fileShareSizeInput += string(r) + } + } + } + return m, nil +} + +func (m Model) handleFileWizardNetworkKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + if m.wizard.fileShareNetworkMenuIdx == 0 { + // Network selection phase + networks := m.wizard.fileShareNetworks + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(networks)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(networks) == 0 { + return m, nil + } + network := networks[m.wizard.selectedIndex] + networkPnId, _ := network["id"].(string) // pn-XXXX_YYYY — used for subnet fetch + name, _ := network["name"].(string) + // _openstackId was pre-extracted by fetchFileShareNetworks for the target region + openstackId, _ := network["_openstackId"].(string) + if openstackId == "" { + openstackId = networkPnId // fallback (should not happen after filtering) + } + + m.wizard.fileShareNetworkId = openstackId + m.wizard.fileShareNetworkName = name + m.wizard.errorMsg = "" + m.wizard.fileShareNetworkMenuIdx = 1 + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading subnets..." + return m, m.fetchFileShareSubnets(networkPnId) + case "left": + m.wizard.step = FileWizardStepSize + m.wizard.fileShareNetworkMenuIdx = 0 + } + } else { + // Subnet selection phase + subnets := m.wizard.fileShareSubnets + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(subnets)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(subnets) == 0 { + return m, nil + } + subnet := subnets[m.wizard.selectedIndex] + subnetId, _ := subnet["id"].(string) + subnetCIDR, _ := subnet["cidr"].(string) + m.wizard.fileShareSubnetId = subnetId + m.wizard.fileShareSubnetCIDR = subnetCIDR + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepConfirm + m.wizard.fileShareConfirmBtnIdx = 0 + case "left": + // Back to network selection + m.wizard.fileShareNetworkMenuIdx = 0 + m.wizard.selectedIndex = 0 + } + } + return m, nil +} + +func (m Model) handleFileWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + switch key { + case "right", "tab": + if m.wizard.fileShareConfirmBtnIdx == 0 { + m.wizard.fileShareConfirmBtnIdx = 1 + } else { + m.wizard.fileShareConfirmBtnIdx = 0 + } + case "enter": + if m.wizard.fileShareConfirmBtnIdx == 0 { + m.wizard.isLoading = true + m.wizard.loadingMessage = "Creating file share..." + return m, m.createFileShare() + } + // Cancel + m.wizard = WizardData{} + m.mode = LoadingView + return m, m.fetchDataForPath("/storage/file") + case "left", "esc": + m.wizard.step = FileWizardStepNetwork + m.wizard.fileShareNetworkMenuIdx = 1 + } + return m, nil +} From 5da2fe7ab7d113730fa6f0293caa3865ae10e11a Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 27 Apr 2026 15:00:06 +0000 Subject: [PATCH 08/55] feat(browser): fixed navigation when you want to change subnav to principal nav Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 82c17163..61e4e321 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -4496,11 +4496,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav (either focused or when on a storage sub-product) - isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive - if (m.inStorageSubNav || isStorageSubProduct) && m.mode != DetailView { + // In storage sub-nav (only when focused) + if m.inStorageSubNav && m.mode != DetailView { subItems := getStorageSubItems() - // Find current index from product for i, item := range subItems { if item.Product == m.currentProduct { m.storageSubIdx = i @@ -4508,7 +4506,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) - m.inStorageSubNav = true return m.loadStorageSubProduct() } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { @@ -4545,11 +4542,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav (either focused or when on a storage sub-product) + // In storage sub-nav (only when focused) isStorageSubProduct2 := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive - if (m.inStorageSubNav || isStorageSubProduct2) && m.mode != DetailView { + if m.inStorageSubNav && isStorageSubProduct2 && m.mode != DetailView { subItems := getStorageSubItems() - // Find current index from product for i, item := range subItems { if item.Product == m.currentProduct { m.storageSubIdx = i @@ -4557,7 +4553,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) - m.inStorageSubNav = true return m.loadStorageSubProduct() } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { @@ -4750,11 +4745,22 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inStorageSubNav = true return m.loadStorageSubProduct() } - // ↑ in sub-nav → exit sub-nav, back to main nav + // ↑ when sub-nav is focused → exit to main nav if (key == "up" || key == "k") && m.inStorageSubNav && m.mode != DetailView { m.inStorageSubNav = false return m, nil } + isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive + // ↑ when at top of table on a storage sub-product → focus sub-nav + if (key == "up" || key == "k") && isStorageSubProduct && !m.inStorageSubNav && m.mode == TableView && m.table.Cursor() == 0 { + m.inStorageSubNav = true + return m, nil + } + // ↑ on EmptyView/ComingSoonView for storage → focus sub-nav + if (key == "up" || key == "k") && isStorageSubProduct && !m.inStorageSubNav && (m.mode == EmptyView || m.mode == ComingSoonView) { + m.inStorageSubNav = true + return m, nil + } // Node pools list navigation if m.mode == NodePoolsView { clusterId := getStringValue(m.detailData, "id", "") From 6c20676f643a830890dbc28b44c338d88e6d8723 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 07:26:23 +0000 Subject: [PATCH 09/55] feat(browser): fixed code created 2 files for file_storage Signed-off-by: olivier dubo --- internal/services/browser/api.go | 314 ---------------- internal/services/browser/file_api.go | 322 ++++++++++++++++ internal/services/browser/file_wizard.go | 446 ++++++++++++++++++++++ internal/services/browser/manager.go | 450 ----------------------- 4 files changed, 768 insertions(+), 764 deletions(-) create mode 100644 internal/services/browser/file_api.go create mode 100644 internal/services/browser/file_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 427ec0a4..e612f67e 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -4037,317 +4037,3 @@ func (m Model) handleNodePoolDeleted(msg nodePoolDeletedMsg) (tea.Model, tea.Cmd return m, m.fetchKubeNodePools(clusterId) } -// ─── File Storage (NFS Share) API ──────────────────────────────────────────── - -// fetchFileShareRegions probes each region concurrently for file storage support -// and returns only regions that have the share API available. -func (m Model) fetchFileShareRegions() tea.Cmd { - return func() tea.Msg { - if m.cloudProject == "" { - return fileShareRegionsLoadedMsg{err: fmt.Errorf("no cloud project selected")} - } - var regionNames []string - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { - return fileShareRegionsLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} - } - - type probeResult struct { - region string - supported bool - } - ch := make(chan probeResult, len(regionNames)) - for _, name := range regionNames { - go func(regionName string) { - probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", - m.cloudProject, url.PathEscape(regionName)) - var result []map[string]interface{} - err := httpLib.Client.Get(probe, &result) - ch <- probeResult{region: regionName, supported: err == nil} - }(name) - } - - var supported []string - for range regionNames { - r := <-ch - if r.supported { - supported = append(supported, r.region) - } - } - sort.Strings(supported) - - if len(supported) == 0 { - return fileShareRegionsLoadedMsg{err: fmt.Errorf("no regions support file storage in this project")} - } - return fileShareRegionsLoadedMsg{regions: supported} - } -} - -// fetchFileShareNetworks fetches available private networks for file share creation -func (m Model) fetchFileShareNetworks() tea.Cmd { - region := m.wizard.selectedRegion - return func() tea.Msg { - if m.cloudProject == "" { - return fileShareNetworksLoadedMsg{err: fmt.Errorf("no cloud project selected")} - } - var networks []map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) - if err := httpLib.Client.Get(endpoint, &networks); err != nil { - return fileShareNetworksLoadedMsg{err: fmt.Errorf("failed to fetch networks: %w", err)} - } - // Pre-extract the OpenStack UUID for our target region into a synthetic field - // so the UI doesn't need to traverse the regions array. - for i, net := range networks { - openstackId := "" - if regions, ok := net["regions"].([]interface{}); ok { - for _, r := range regions { - if rm, ok := r.(map[string]interface{}); ok { - if rm["region"] == region { - openstackId, _ = rm["openstackId"].(string) - break - } - } - } - } - // Only include networks that are active in the target region - if openstackId != "" { - networks[i]["_openstackId"] = openstackId - } - } - // Filter to networks that have the region active - var filtered []map[string]interface{} - for _, net := range networks { - if _, ok := net["_openstackId"]; ok { - filtered = append(filtered, net) - } - } - sort.Slice(filtered, func(i, j int) bool { - iName, _ := filtered[i]["name"].(string) - jName, _ := filtered[j]["name"].(string) - return iName < jName - }) - return fileShareNetworksLoadedMsg{networks: filtered} - } -} - -// fetchFileShareSubnets fetches available subnets for a specific private network -func (m Model) fetchFileShareSubnets(networkID string) tea.Cmd { - return func() tea.Msg { - if m.cloudProject == "" { - return fileShareSubnetsLoadedMsg{err: fmt.Errorf("no cloud project selected")} - } - var subnets []map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", m.cloudProject, networkID) - if err := httpLib.Client.Get(endpoint, &subnets); err != nil { - return fileShareSubnetsLoadedMsg{err: fmt.Errorf("failed to fetch subnets: %w", err)} - } - sort.Slice(subnets, func(i, j int) bool { - iCIDR, _ := subnets[i]["cidr"].(string) - jCIDR, _ := subnets[j]["cidr"].(string) - return iCIDR < jCIDR - }) - return fileShareSubnetsLoadedMsg{subnets: subnets} - } -} - -// createFileShare creates a new NFS file share -func (m Model) createFileShare() tea.Cmd { - return func() tea.Msg { - if m.cloudProject == "" { - return fileShareCreatedMsg{err: fmt.Errorf("no cloud project selected")} - } - body := map[string]interface{}{ - "name": m.wizard.fileShareName, - "type": m.wizard.fileShareType, - "size": m.wizard.fileShareSize, - "networkId": m.wizard.fileShareNetworkId, - "subnetId": m.wizard.fileShareSubnetId, - } - var share map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", - m.cloudProject, url.PathEscape(m.wizard.selectedRegion)) - if err := httpLib.Client.Post(endpoint, body, &share); err != nil { - return fileShareCreatedMsg{err: fmt.Errorf("failed to create file share: %w", err)} - } - return fileShareCreatedMsg{share: share} - } -} - -// fetchFileStorageData fetches the list of NFS file shares across all supported regions -func (m Model) fetchFileStorageData() dataLoadedMsg { - if m.cloudProject == "" { - return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} - } - var regionNames []string - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { - return dataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} - } - - type regionResult struct { - shares []map[string]interface{} - } - ch := make(chan regionResult, len(regionNames)) - for _, name := range regionNames { - go func(regionName string) { - probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", - m.cloudProject, url.PathEscape(regionName)) - var shares []map[string]interface{} - if err := httpLib.Client.Get(probe, &shares); err != nil { - ch <- regionResult{} - return - } - // Tag each share with its region - for i := range shares { - shares[i]["region"] = regionName - } - ch <- regionResult{shares: shares} - }(name) - } - - var allShares []map[string]interface{} - for range regionNames { - r := <-ch - allShares = append(allShares, r.shares...) - } - - // Sort by creation date or name - sort.Slice(allShares, func(i, j int) bool { - iName, _ := allShares[i]["name"].(string) - jName, _ := allShares[j]["name"].(string) - return iName < jName - }) - - return dataLoadedMsg{data: allShares} -} - -// createFileStorageTable creates a table rendering file storage shares -func createFileStorageTable(data []map[string]interface{}, width, height int) table.Model { - columns := []table.Column{ - {Title: "Nom", Width: 25}, - {Title: "ID", Width: 20}, - {Title: "Région", Width: 12}, - {Title: "Type", Width: 16}, - {Title: "Capacité", Width: 12}, - {Title: "Statut", Width: 12}, - } - - var rows []table.Row - for _, share := range data { - name := getString(share, "name") - id := getString(share, "id") - region := getString(share, "region") - shareType := getString(share, "type") - status := getString(share, "status") - - // Size - sizeStr := "-" - if sz, ok := share["size"]; ok { - switch v := sz.(type) { - case float64: - sizeStr = fmt.Sprintf("%d GB", int(v)) - case json.Number: - if f, err := v.Float64(); err == nil { - sizeStr = fmt.Sprintf("%d GB", int(f)) - } - case int: - sizeStr = fmt.Sprintf("%d GB", v) - } - } - - rows = append(rows, table.Row{name, id, region, shareType, sizeStr, status}) - } - - tableHeight := height - 15 - if tableHeight < 5 { - tableHeight = 5 - } - if tableHeight > 20 { - tableHeight = 20 - } - - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(tableHeight), - ) - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - t.SetStyles(s) - return t -} - -// ─── File Storage wizard message handlers ──────────────────────────────────── - -func (m Model) handleFileShareRegionsLoaded(msg fileShareRegionsLoadedMsg) (tea.Model, tea.Cmd) { - m.wizard.isLoading = false - m.wizard.loadingMessage = "" - if msg.err != nil { - m.wizard.errorMsg = msg.err.Error() - return m, nil - } - m.wizard.fileShareRegions = msg.regions - if len(msg.regions) == 0 { - m.wizard.errorMsg = "No regions support file storage in this project" - return m, nil - } - m.wizard.selectedIndex = 0 - return m, nil -} - -func (m Model) handleFileShareNetworksLoaded(msg fileShareNetworksLoadedMsg) (tea.Model, tea.Cmd) { - m.wizard.isLoading = false - m.wizard.loadingMessage = "" - if msg.err != nil { - m.wizard.errorMsg = msg.err.Error() - return m, nil - } - m.wizard.fileShareNetworks = msg.networks - m.wizard.selectedIndex = 0 - return m, nil -} - -func (m Model) handleFileShareSubnetsLoaded(msg fileShareSubnetsLoadedMsg) (tea.Model, tea.Cmd) { - m.wizard.isLoading = false - m.wizard.loadingMessage = "" - if msg.err != nil { - m.wizard.errorMsg = msg.err.Error() - return m, nil - } - m.wizard.fileShareSubnets = msg.subnets - m.wizard.selectedIndex = 0 - return m, nil -} - -func (m Model) handleFileShareCreated(msg fileShareCreatedMsg) (tea.Model, tea.Cmd) { - m.wizard.isLoading = false - m.wizard.loadingMessage = "" - if msg.err != nil { - m.wizard.errorMsg = msg.err.Error() - return m, nil - } - name := getString(msg.share, "name") - if name == "" { - name = "file share" - } - m.notification = fmt.Sprintf("✅ File share '%s' created successfully!", name) - m.notificationExpiry = time.Now().Add(5 * time.Second) - m.wizard = WizardData{} - m.mode = LoadingView - return m, tea.Batch( - m.fetchDataForPath("/storage/file"), - tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return clearNotificationMsg{} - }), - ) -} - diff --git a/internal/services/browser/file_api.go b/internal/services/browser/file_api.go new file mode 100644 index 00000000..738921cc --- /dev/null +++ b/internal/services/browser/file_api.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "encoding/json" + "fmt" + "net/url" + "sort" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// ─── File Storage (NFS Share) API ───────────────────────────────────────────── + +// fetchFileShareRegions probes each region concurrently for file storage support. +func (m Model) fetchFileShareRegions() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + type probeResult struct { + region string + supported bool + } + ch := make(chan probeResult, len(regionNames)) + for _, name := range regionNames { + go func(regionName string) { + probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(regionName)) + var result []map[string]interface{} + err := httpLib.Client.Get(probe, &result) + ch <- probeResult{region: regionName, supported: err == nil} + }(name) + } + + var supported []string + for range regionNames { + r := <-ch + if r.supported { + supported = append(supported, r.region) + } + } + sort.Strings(supported) + + if len(supported) == 0 { + return fileShareRegionsLoadedMsg{err: fmt.Errorf("no regions support file storage in this project")} + } + return fileShareRegionsLoadedMsg{regions: supported} + } +} + +// fetchFileShareNetworks fetches private networks available for the selected region, +// pre-extracting the OpenStack UUID needed by the file share API. +func (m Model) fetchFileShareNetworks() tea.Cmd { + region := m.wizard.selectedRegion + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareNetworksLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var networks []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) + if err := httpLib.Client.Get(endpoint, &networks); err != nil { + return fileShareNetworksLoadedMsg{err: fmt.Errorf("failed to fetch networks: %w", err)} + } + for i, net := range networks { + openstackId := "" + if regions, ok := net["regions"].([]interface{}); ok { + for _, r := range regions { + if rm, ok := r.(map[string]interface{}); ok { + if rm["region"] == region { + openstackId, _ = rm["openstackId"].(string) + break + } + } + } + } + if openstackId != "" { + networks[i]["_openstackId"] = openstackId + } + } + var filtered []map[string]interface{} + for _, net := range networks { + if _, ok := net["_openstackId"]; ok { + filtered = append(filtered, net) + } + } + sort.Slice(filtered, func(i, j int) bool { + iName, _ := filtered[i]["name"].(string) + jName, _ := filtered[j]["name"].(string) + return iName < jName + }) + return fileShareNetworksLoadedMsg{networks: filtered} + } +} + +// fetchFileShareSubnets fetches subnets for a private network. +func (m Model) fetchFileShareSubnets(networkID string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareSubnetsLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var subnets []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", m.cloudProject, networkID) + if err := httpLib.Client.Get(endpoint, &subnets); err != nil { + return fileShareSubnetsLoadedMsg{err: fmt.Errorf("failed to fetch subnets: %w", err)} + } + sort.Slice(subnets, func(i, j int) bool { + iCIDR, _ := subnets[i]["cidr"].(string) + jCIDR, _ := subnets[j]["cidr"].(string) + return iCIDR < jCIDR + }) + return fileShareSubnetsLoadedMsg{subnets: subnets} + } +} + +// createFileShare creates a new NFS file share via the OVH API. +func (m Model) createFileShare() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fileShareCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + body := map[string]interface{}{ + "name": m.wizard.fileShareName, + "type": m.wizard.fileShareType, + "size": m.wizard.fileShareSize, + "networkId": m.wizard.fileShareNetworkId, + "subnetId": m.wizard.fileShareSubnetId, + } + var share map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(m.wizard.selectedRegion)) + if err := httpLib.Client.Post(endpoint, body, &share); err != nil { + return fileShareCreatedMsg{err: fmt.Errorf("failed to create file share: %w", err)} + } + return fileShareCreatedMsg{share: share} + } +} + +// fetchFileStorageData returns all NFS shares across every region. +func (m Model) fetchFileStorageData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + return dataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + type regionResult struct{ shares []map[string]interface{} } + ch := make(chan regionResult, len(regionNames)) + for _, name := range regionNames { + go func(regionName string) { + probe := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", + m.cloudProject, url.PathEscape(regionName)) + var shares []map[string]interface{} + if err := httpLib.Client.Get(probe, &shares); err != nil { + ch <- regionResult{} + return + } + for i := range shares { + shares[i]["region"] = regionName + } + ch <- regionResult{shares: shares} + }(name) + } + + var allShares []map[string]interface{} + for range regionNames { + r := <-ch + allShares = append(allShares, r.shares...) + } + sort.Slice(allShares, func(i, j int) bool { + iName, _ := allShares[i]["name"].(string) + jName, _ := allShares[j]["name"].(string) + return iName < jName + }) + return dataLoadedMsg{data: allShares} +} + +// createFileStorageTable builds the table model for file shares. +func createFileStorageTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 25}, + {Title: "ID", Width: 20}, + {Title: "Région", Width: 12}, + {Title: "Type", Width: 16}, + {Title: "Capacité", Width: 12}, + {Title: "Statut", Width: 12}, + } + + var rows []table.Row + for _, share := range data { + name := getString(share, "name") + id := getString(share, "id") + region := getString(share, "region") + shareType := getString(share, "type") + status := getString(share, "status") + + sizeStr := "-" + if sz, ok := share["size"]; ok { + switch v := sz.(type) { + case float64: + sizeStr = fmt.Sprintf("%d GB", int(v)) + case json.Number: + if f, err := v.Float64(); err == nil { + sizeStr = fmt.Sprintf("%d GB", int(f)) + } + case int: + sizeStr = fmt.Sprintf("%d GB", v) + } + } + rows = append(rows, table.Row{name, id, region, shareType, sizeStr, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + return t +} + +// ─── File Storage message handlers ──────────────────────────────────────────── + +func (m Model) handleFileShareRegionsLoaded(msg fileShareRegionsLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareRegions = msg.regions + if len(msg.regions) == 0 { + m.wizard.errorMsg = "No regions support file storage in this project" + return m, nil + } + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareNetworksLoaded(msg fileShareNetworksLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareNetworks = msg.networks + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareSubnetsLoaded(msg fileShareSubnetsLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fileShareSubnets = msg.subnets + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleFileShareCreated(msg fileShareCreatedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + name := getString(msg.share, "name") + if name == "" { + name = "file share" + } + m.notification = fmt.Sprintf("✅ File share '%s' created successfully!", name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.wizard = WizardData{} + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/file"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} diff --git a/internal/services/browser/file_wizard.go b/internal/services/browser/file_wizard.go new file mode 100644 index 00000000..1fc65275 --- /dev/null +++ b/internal/services/browser/file_wizard.go @@ -0,0 +1,446 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ─── File Storage wizard render functions ───────────────────────────────────── + +func (m Model) renderFileWizardNameStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Enter a name for the file share:") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + content.WriteString(inputStyle.Render(m.wizard.fileShareNameInput+"▌") + "\n\n") + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Type to enter • Enter: Continue • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardRegionStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select region for the file share:") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("Loading available regions...")) + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, region := range m.wizard.fileShareRegions { + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + region)) + } else { + content.WriteString(listStyle.Render(" " + region)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardTypeStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Select performance mode:") + "\n\n") + + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + content.WriteString(descStyle.Render("Choose the performance tier for the NFS share.") + "\n\n") + + types := []string{"standard-1az"} + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + + for i, t := range types { + if i == m.wizard.fileShareTypeIdx { + content.WriteString(selectedStyle.Render("▶ " + t)) + } else { + content.WriteString(listStyle.Render(" " + t)) + } + content.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardSizeStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Enter file share size (GB):") + "\n\n") + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(20) + content.WriteString(inputStyle.Render(m.wizard.fileShareSizeInput+"▌") + "\n\n") + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render("Type size in GB • Enter: Continue • ←: Back • Esc: Cancel")) + return content.String() +} + +func (m Model) renderFileWizardNetworkStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + + if m.wizard.isLoading { + if m.wizard.fileShareNetworkMenuIdx == 0 { + content.WriteString(titleStyle.Render("Select private network:") + "\n\n") + content.WriteString(loadingStyle.Render("Loading networks...")) + } else { + content.WriteString(titleStyle.Render("Select subnet:") + "\n\n") + content.WriteString(loadingStyle.Render("Loading subnets...")) + } + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + if m.wizard.fileShareNetworkMenuIdx == 0 { + content.WriteString(titleStyle.Render("Select private network for the file share:") + "\n\n") + for i, network := range m.wizard.fileShareNetworks { + name, _ := network["name"].(string) + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + name)) + } else { + content.WriteString(listStyle.Render(" " + name)) + } + content.WriteString("\n") + } + if len(m.wizard.fileShareNetworks) == 0 { + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + content.WriteString(dimStyle.Render(" No private networks available in this region.") + "\n") + } + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) + } else { + content.WriteString(titleStyle.Render(fmt.Sprintf("Select subnet (network: %s):", m.wizard.fileShareNetworkName)) + "\n\n") + for i, subnet := range m.wizard.fileShareSubnets { + cidr, _ := subnet["cidr"].(string) + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render("▶ " + cidr)) + } else { + content.WriteString(listStyle.Render(" " + cidr)) + } + content.WriteString("\n") + } + if len(m.wizard.fileShareSubnets) == 0 { + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + content.WriteString(dimStyle.Render(" No subnets available for this network.") + "\n") + } + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) + content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back to networks • Esc: Cancel")) + } + return content.String() +} + +func (m Model) renderFileWizardConfirmStep(width int) string { + var content strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + regionStr := m.wizard.selectedRegion + + content.WriteString(titleStyle.Render("Confirm file share creation:") + "\n\n") + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.fileShareName) + "\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(regionStr) + "\n") + content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(m.wizard.fileShareType) + "\n") + content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.fileShareSize)) + "\n") + content.WriteString(labelStyle.Render(" Network:") + valueStyle.Render(m.wizard.fileShareNetworkName) + "\n") + content.WriteString(labelStyle.Render(" Subnet:") + valueStyle.Render(m.wizard.fileShareSubnetCIDR) + "\n") + content.WriteString("\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render(m.wizard.loadingMessage)) + return content.String() + } + + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + createStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + if m.wizard.fileShareConfirmBtnIdx == 0 { + content.WriteString(createStyle.Render(" ▶ [Create File Share]") + " ") + content.WriteString(dimStyle.Render("[Cancel]") + "\n") + } else { + content.WriteString(dimStyle.Render(" [Create File Share]") + " ") + content.WriteString(cancelStyle.Render("▶ [Cancel]") + "\n") + } + + return content.String() +} + +// ─── File Storage wizard key handler functions ──────────────────────────────── + +func (m Model) handleFileWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + name := strings.TrimSpace(m.wizard.fileShareNameInput) + if name == "" { + m.wizard.errorMsg = "File share name cannot be empty" + return m, nil + } + m.wizard.fileShareName = name + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepRegion + m.wizard.selectedIndex = 0 + if len(m.wizard.fileShareRegions) > 0 { + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading available regions..." + return m, m.fetchFileShareRegions() + case tea.KeyBackspace: + if len(m.wizard.fileShareNameInput) > 0 { + m.wizard.fileShareNameInput = m.wizard.fileShareNameInput[:len(m.wizard.fileShareNameInput)-1] + } + case tea.KeyRunes: + m.wizard.fileShareNameInput += string(msg.Runes) + } + return m, nil +} + +func (m Model) handleFileWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + regions := m.wizard.fileShareRegions + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(regions)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(regions) == 0 { + return m, nil + } + m.wizard.selectedRegion = regions[m.wizard.selectedIndex] + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepType + m.wizard.fileShareTypeIdx = 0 + m.wizard.fileShareType = "standard-1az" + case "left": + m.wizard.step = FileWizardStepName + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleFileWizardTypeKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + types := []string{"standard-1az"} + switch key { + case "up", "k": + if m.wizard.fileShareTypeIdx > 0 { + m.wizard.fileShareTypeIdx-- + } + case "down", "j": + if m.wizard.fileShareTypeIdx < len(types)-1 { + m.wizard.fileShareTypeIdx++ + } + case "enter": + m.wizard.fileShareType = types[m.wizard.fileShareTypeIdx] + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepSize + if m.wizard.fileShareSize > 0 { + m.wizard.fileShareSizeInput = fmt.Sprintf("%d", m.wizard.fileShareSize) + } else { + m.wizard.fileShareSizeInput = "" + } + case "left": + m.wizard.step = FileWizardStepRegion + m.wizard.selectedIndex = 0 + } + return m, nil +} + +func (m Model) handleFileWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + sizeStr := strings.TrimSpace(m.wizard.fileShareSizeInput) + size, err := strconv.Atoi(sizeStr) + if err != nil || size < 1 { + m.wizard.errorMsg = "Size must be a positive integer (GB)" + return m, nil + } + m.wizard.fileShareSize = size + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepNetwork + m.wizard.fileShareNetworkMenuIdx = 0 + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading private networks..." + return m, m.fetchFileShareNetworks() + case tea.KeyBackspace: + if len(m.wizard.fileShareSizeInput) > 0 { + m.wizard.fileShareSizeInput = m.wizard.fileShareSizeInput[:len(m.wizard.fileShareSizeInput)-1] + } + case tea.KeyLeft: + m.wizard.step = FileWizardStepType + m.wizard.fileShareTypeIdx = 0 + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + m.wizard.fileShareSizeInput += string(r) + } + } + } + return m, nil +} + +func (m Model) handleFileWizardNetworkKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + if m.wizard.fileShareNetworkMenuIdx == 0 { + networks := m.wizard.fileShareNetworks + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(networks)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(networks) == 0 { + return m, nil + } + network := networks[m.wizard.selectedIndex] + networkPnId, _ := network["id"].(string) + name, _ := network["name"].(string) + openstackId, _ := network["_openstackId"].(string) + if openstackId == "" { + openstackId = networkPnId + } + m.wizard.fileShareNetworkId = openstackId + m.wizard.fileShareNetworkName = name + m.wizard.errorMsg = "" + m.wizard.fileShareNetworkMenuIdx = 1 + m.wizard.selectedIndex = 0 + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading subnets..." + return m, m.fetchFileShareSubnets(networkPnId) + case "left": + m.wizard.step = FileWizardStepSize + m.wizard.fileShareNetworkMenuIdx = 0 + } + } else { + subnets := m.wizard.fileShareSubnets + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(subnets)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(subnets) == 0 { + return m, nil + } + subnet := subnets[m.wizard.selectedIndex] + subnetId, _ := subnet["id"].(string) + subnetCIDR, _ := subnet["cidr"].(string) + m.wizard.fileShareSubnetId = subnetId + m.wizard.fileShareSubnetCIDR = subnetCIDR + m.wizard.errorMsg = "" + m.wizard.step = FileWizardStepConfirm + m.wizard.fileShareConfirmBtnIdx = 0 + case "left": + m.wizard.fileShareNetworkMenuIdx = 0 + m.wizard.selectedIndex = 0 + } + } + return m, nil +} + +func (m Model) handleFileWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + if m.wizard.isLoading { + return m, nil + } + switch key { + case "right", "tab": + if m.wizard.fileShareConfirmBtnIdx == 0 { + m.wizard.fileShareConfirmBtnIdx = 1 + } else { + m.wizard.fileShareConfirmBtnIdx = 0 + } + case "enter": + if m.wizard.fileShareConfirmBtnIdx == 0 { + m.wizard.isLoading = true + m.wizard.loadingMessage = "Creating file share..." + return m, m.createFileShare() + } + m.wizard = WizardData{} + m.mode = LoadingView + return m, m.fetchDataForPath("/storage/file") + case "left", "esc": + m.wizard.step = FileWizardStepNetwork + m.wizard.fileShareNetworkMenuIdx = 1 + } + return m, nil +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 61e4e321..4167a1bf 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -7315,453 +7315,3 @@ func (m Model) handleNodePoolDeleteConfirmKeyPress(msg tea.KeyMsg) (tea.Model, t } } -// ─── File Storage wizard render functions ───────────────────────────────────── - -func (m Model) renderFileWizardNameStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Enter a name for the file share:") + "\n\n") - - if m.wizard.errorMsg != "" { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") - } - - inputStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00FF7F")). - Padding(0, 1). - Width(40) - content.WriteString(inputStyle.Render(m.wizard.fileShareNameInput+"▌") + "\n\n") - - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(helpStyle.Render("Type to enter • Enter: Continue • Esc: Cancel")) - return content.String() -} - -func (m Model) renderFileWizardRegionStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Select region for the file share:") + "\n\n") - - if m.wizard.isLoading { - content.WriteString(loadingStyle.Render("Loading available regions...")) - return content.String() - } - - if m.wizard.errorMsg != "" { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") - } - - listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) - selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) - - for i, region := range m.wizard.fileShareRegions { - if i == m.wizard.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + region)) - } else { - content.WriteString(listStyle.Render(" " + region)) - } - content.WriteString("\n") - } - - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) - content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) - return content.String() -} - -func (m Model) renderFileWizardTypeStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Select performance mode:") + "\n\n") - - descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - content.WriteString(descStyle.Render("Choose the performance tier for the NFS share.") + "\n\n") - - types := []string{"standard-1az"} - listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) - selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) - - for i, t := range types { - if i == m.wizard.fileShareTypeIdx { - content.WriteString(selectedStyle.Render("▶ " + t)) - } else { - content.WriteString(listStyle.Render(" " + t)) - } - content.WriteString("\n") - } - - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) - content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) - return content.String() -} - -func (m Model) renderFileWizardSizeStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Enter file share size (GB):") + "\n\n") - - if m.wizard.errorMsg != "" { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") - } - - inputStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00FF7F")). - Padding(0, 1). - Width(20) - content.WriteString(inputStyle.Render(m.wizard.fileShareSizeInput+"▌") + "\n\n") - - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(helpStyle.Render("Type size in GB • Enter: Continue • ←: Back • Esc: Cancel")) - return content.String() -} - -func (m Model) renderFileWizardNetworkStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - - if m.wizard.isLoading { - if m.wizard.fileShareNetworkMenuIdx == 0 { - content.WriteString(titleStyle.Render("Select private network:") + "\n\n") - content.WriteString(loadingStyle.Render("Loading networks...")) - } else { - content.WriteString(titleStyle.Render("Select subnet:") + "\n\n") - content.WriteString(loadingStyle.Render("Loading subnets...")) - } - return content.String() - } - - if m.wizard.errorMsg != "" { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") - } - - selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) - listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) - - if m.wizard.fileShareNetworkMenuIdx == 0 { - // Show network list - content.WriteString(titleStyle.Render("Select private network for the file share:") + "\n\n") - for i, network := range m.wizard.fileShareNetworks { - name, _ := network["name"].(string) - if i == m.wizard.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + name)) - } else { - content.WriteString(listStyle.Render(" " + name)) - } - content.WriteString("\n") - } - if len(m.wizard.fileShareNetworks) == 0 { - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - content.WriteString(dimStyle.Render(" No private networks available in this region.") + "\n") - } - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) - content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back • Esc: Cancel")) - } else { - // Show subnet list - content.WriteString(titleStyle.Render(fmt.Sprintf("Select subnet (network: %s):", m.wizard.fileShareNetworkName)) + "\n\n") - for i, subnet := range m.wizard.fileShareSubnets { - cidr, _ := subnet["cidr"].(string) - if i == m.wizard.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + cidr)) - } else { - content.WriteString(listStyle.Render(" " + cidr)) - } - content.WriteString("\n") - } - if len(m.wizard.fileShareSubnets) == 0 { - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - content.WriteString(dimStyle.Render(" No subnets available for this network.") + "\n") - } - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0) - content.WriteString(helpStyle.Render("↑↓ Navigate • Enter: Select • ← Back to networks • Esc: Cancel")) - } - return content.String() -} - -func (m Model) renderFileWizardConfirmStep(width int) string { - var content strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Confirm file share creation:") + "\n\n") - - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) - valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - - content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.fileShareName) + "\n") - content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.fileShareRegions[m.wizard.selectedIndex]) + "\n") - - // Actually selectedIndex won't be meaningful here; store the selected region - regionStr := m.wizard.fileShareRegions[0] - if len(m.wizard.fileShareRegions) > 0 { - // We stored the region in selectedRegion when we moved past the region step - regionStr = m.wizard.selectedRegion - } - content = strings.Builder{} - content.WriteString(titleStyle.Render("Confirm file share creation:") + "\n\n") - content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.fileShareName) + "\n") - content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(regionStr) + "\n") - content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(m.wizard.fileShareType) + "\n") - content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.fileShareSize)) + "\n") - content.WriteString(labelStyle.Render(" Network:") + valueStyle.Render(m.wizard.fileShareNetworkName) + "\n") - content.WriteString(labelStyle.Render(" Subnet:") + valueStyle.Render(m.wizard.fileShareSubnetCIDR) + "\n") - - content.WriteString("\n") - - if m.wizard.isLoading { - content.WriteString(loadingStyle.Render(m.wizard.loadingMessage)) - return content.String() - } - - if m.wizard.errorMsg != "" { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - content.WriteString(errStyle.Render("Error: "+m.wizard.errorMsg) + "\n\n") - } - - createStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) - cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - - if m.wizard.fileShareConfirmBtnIdx == 0 { - content.WriteString(createStyle.Render(" ▶ [Create File Share]") + " ") - content.WriteString(dimStyle.Render("[Cancel]") + "\n") - } else { - content.WriteString(dimStyle.Render(" [Create File Share]") + " ") - content.WriteString(cancelStyle.Render("▶ [Cancel]") + "\n") - } - - return content.String() -} - -// ─── File Storage wizard key handler functions ──────────────────────────────── - -func (m Model) handleFileWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: - name := strings.TrimSpace(m.wizard.fileShareNameInput) - if name == "" { - m.wizard.errorMsg = "File share name cannot be empty" - return m, nil - } - m.wizard.fileShareName = name - m.wizard.errorMsg = "" - m.wizard.step = FileWizardStepRegion - m.wizard.selectedIndex = 0 - if len(m.wizard.fileShareRegions) > 0 { - // Regions already loaded - return m, nil - } - // Should not happen (we load regions at wizard start) but just in case - m.wizard.isLoading = true - m.wizard.loadingMessage = "Loading available regions..." - return m, m.fetchFileShareRegions() - case tea.KeyBackspace: - if len(m.wizard.fileShareNameInput) > 0 { - m.wizard.fileShareNameInput = m.wizard.fileShareNameInput[:len(m.wizard.fileShareNameInput)-1] - } - case tea.KeyRunes: - m.wizard.fileShareNameInput += string(msg.Runes) - } - return m, nil -} - -func (m Model) handleFileWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.wizard.isLoading { - return m, nil - } - regions := m.wizard.fileShareRegions - switch key { - case "up", "k": - if m.wizard.selectedIndex > 0 { - m.wizard.selectedIndex-- - } - case "down", "j": - if m.wizard.selectedIndex < len(regions)-1 { - m.wizard.selectedIndex++ - } - case "enter": - if len(regions) == 0 { - return m, nil - } - m.wizard.selectedRegion = regions[m.wizard.selectedIndex] - m.wizard.errorMsg = "" - m.wizard.step = FileWizardStepType - m.wizard.fileShareTypeIdx = 0 - m.wizard.fileShareType = "standard-1az" - case "left": - m.wizard.step = FileWizardStepName - m.wizard.selectedIndex = 0 - } - return m, nil -} - -func (m Model) handleFileWizardTypeKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { - types := []string{"standard-1az"} - switch key { - case "up", "k": - if m.wizard.fileShareTypeIdx > 0 { - m.wizard.fileShareTypeIdx-- - } - case "down", "j": - if m.wizard.fileShareTypeIdx < len(types)-1 { - m.wizard.fileShareTypeIdx++ - } - case "enter": - m.wizard.fileShareType = types[m.wizard.fileShareTypeIdx] - m.wizard.errorMsg = "" - m.wizard.step = FileWizardStepSize - if m.wizard.fileShareSize > 0 { - m.wizard.fileShareSizeInput = fmt.Sprintf("%d", m.wizard.fileShareSize) - } else { - m.wizard.fileShareSizeInput = "" - } - case "left": - m.wizard.step = FileWizardStepRegion - m.wizard.selectedIndex = 0 - } - return m, nil -} - -func (m Model) handleFileWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: - sizeStr := strings.TrimSpace(m.wizard.fileShareSizeInput) - size, err := strconv.Atoi(sizeStr) - if err != nil || size < 1 { - m.wizard.errorMsg = "Size must be a positive integer (GB)" - return m, nil - } - m.wizard.fileShareSize = size - m.wizard.errorMsg = "" - m.wizard.step = FileWizardStepNetwork - m.wizard.fileShareNetworkMenuIdx = 0 - m.wizard.selectedIndex = 0 - m.wizard.isLoading = true - m.wizard.loadingMessage = "Loading private networks..." - return m, m.fetchFileShareNetworks() - case tea.KeyBackspace: - if len(m.wizard.fileShareSizeInput) > 0 { - m.wizard.fileShareSizeInput = m.wizard.fileShareSizeInput[:len(m.wizard.fileShareSizeInput)-1] - } - case tea.KeyLeft: - m.wizard.step = FileWizardStepType - m.wizard.fileShareTypeIdx = 0 - case tea.KeyRunes: - for _, r := range msg.Runes { - if r >= '0' && r <= '9' { - m.wizard.fileShareSizeInput += string(r) - } - } - } - return m, nil -} - -func (m Model) handleFileWizardNetworkKeys(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.wizard.isLoading { - return m, nil - } - if m.wizard.fileShareNetworkMenuIdx == 0 { - // Network selection phase - networks := m.wizard.fileShareNetworks - switch key { - case "up", "k": - if m.wizard.selectedIndex > 0 { - m.wizard.selectedIndex-- - } - case "down", "j": - if m.wizard.selectedIndex < len(networks)-1 { - m.wizard.selectedIndex++ - } - case "enter": - if len(networks) == 0 { - return m, nil - } - network := networks[m.wizard.selectedIndex] - networkPnId, _ := network["id"].(string) // pn-XXXX_YYYY — used for subnet fetch - name, _ := network["name"].(string) - // _openstackId was pre-extracted by fetchFileShareNetworks for the target region - openstackId, _ := network["_openstackId"].(string) - if openstackId == "" { - openstackId = networkPnId // fallback (should not happen after filtering) - } - - m.wizard.fileShareNetworkId = openstackId - m.wizard.fileShareNetworkName = name - m.wizard.errorMsg = "" - m.wizard.fileShareNetworkMenuIdx = 1 - m.wizard.selectedIndex = 0 - m.wizard.isLoading = true - m.wizard.loadingMessage = "Loading subnets..." - return m, m.fetchFileShareSubnets(networkPnId) - case "left": - m.wizard.step = FileWizardStepSize - m.wizard.fileShareNetworkMenuIdx = 0 - } - } else { - // Subnet selection phase - subnets := m.wizard.fileShareSubnets - switch key { - case "up", "k": - if m.wizard.selectedIndex > 0 { - m.wizard.selectedIndex-- - } - case "down", "j": - if m.wizard.selectedIndex < len(subnets)-1 { - m.wizard.selectedIndex++ - } - case "enter": - if len(subnets) == 0 { - return m, nil - } - subnet := subnets[m.wizard.selectedIndex] - subnetId, _ := subnet["id"].(string) - subnetCIDR, _ := subnet["cidr"].(string) - m.wizard.fileShareSubnetId = subnetId - m.wizard.fileShareSubnetCIDR = subnetCIDR - m.wizard.errorMsg = "" - m.wizard.step = FileWizardStepConfirm - m.wizard.fileShareConfirmBtnIdx = 0 - case "left": - // Back to network selection - m.wizard.fileShareNetworkMenuIdx = 0 - m.wizard.selectedIndex = 0 - } - } - return m, nil -} - -func (m Model) handleFileWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { - if m.wizard.isLoading { - return m, nil - } - switch key { - case "right", "tab": - if m.wizard.fileShareConfirmBtnIdx == 0 { - m.wizard.fileShareConfirmBtnIdx = 1 - } else { - m.wizard.fileShareConfirmBtnIdx = 0 - } - case "enter": - if m.wizard.fileShareConfirmBtnIdx == 0 { - m.wizard.isLoading = true - m.wizard.loadingMessage = "Creating file share..." - return m, m.createFileShare() - } - // Cancel - m.wizard = WizardData{} - m.mode = LoadingView - return m, m.fetchDataForPath("/storage/file") - case "left", "esc": - m.wizard.step = FileWizardStepNetwork - m.wizard.fileShareNetworkMenuIdx = 1 - } - return m, nil -} From fa03dfe47935b2486c6a13ab0acb75671537effe Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 08:16:20 +0000 Subject: [PATCH 10/55] feat(browser): added details in file_storage for more informations Signed-off-by: olivier dubo --- internal/services/browser/file_api.go | 93 ++++++ internal/services/browser/manager.go | 48 ++- .../browser/views/file_storage/detail.go | 300 ++++++++++++++++++ 3 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 internal/services/browser/views/file_storage/detail.go diff --git a/internal/services/browser/file_api.go b/internal/services/browser/file_api.go index 738921cc..bf39278c 100644 --- a/internal/services/browser/file_api.go +++ b/internal/services/browser/file_api.go @@ -11,12 +11,14 @@ import ( "fmt" "net/url" "sort" + "strconv" "time" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" httpLib "github.com/ovh/ovhcloud-cli/internal/http" + file_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/file_storage" ) // ─── File Storage (NFS Share) API ───────────────────────────────────────────── @@ -320,3 +322,94 @@ func (m Model) handleFileShareCreated(msg fileShareCreatedMsg) (tea.Model, tea.C }), ) } + +// ─── File Storage detail-view actions ───────────────────────────────────────── + +type fileShareActionDoneMsg struct { + action int + err error +} + +func (m Model) deleteFileShare(shareId, region string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(shareId)) + err := httpLib.Client.Delete(endpoint, nil) + return fileShareActionDoneMsg{action: file_storage.FileShareActionDelete, err: err} + } +} + +func (m Model) renameFileShare(shareId, region, newName string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(shareId)) + body := map[string]interface{}{"name": newName} + err := httpLib.Client.Put(endpoint, body, nil) + return fileShareActionDoneMsg{action: file_storage.FileShareActionRename, err: err} + } +} + +func (m Model) extendFileShare(shareId, region string, newSizeGB int) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(shareId)) + body := map[string]interface{}{"size": newSizeGB} + err := httpLib.Client.Put(endpoint, body, nil) + return fileShareActionDoneMsg{action: file_storage.FileShareActionExtend, err: err} + } +} + +func (m Model) handleExecuteFileShareAction(msg file_storage.ExecuteFileShareActionMsg) (tea.Model, tea.Cmd) { + shareId := getString(msg.Share, "id") + region := getString(msg.Share, "region") + shareName := getString(msg.Share, "name") + + switch msg.Action { + case file_storage.FileShareActionDelete: + m.notification = fmt.Sprintf("🗑️ Suppression du partage '%s'...", shareName) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.deleteFileShare(shareId, region) + case file_storage.FileShareActionRename: + m.notification = "✏️ Renommage du partage..." + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.renameFileShare(shareId, region, msg.Param) + case file_storage.FileShareActionExtend: + newSize, err := strconv.Atoi(msg.Param) + if err != nil || newSize < 1 { + m.notification = "❌ Taille invalide" + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, nil + } + m.notification = fmt.Sprintf("⬆️ Extension du partage à %d GB...", newSize) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.extendFileShare(shareId, region, newSize) + } + return m, nil +} + +func (m Model) handleFileShareActionDone(msg fileShareActionDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Action échouée: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + actionNames := []string{"supprimé", "renommé", "étendu"} + actionName := "mis à jour" + if msg.action >= 0 && msg.action < len(actionNames) { + actionName = actionNames[msg.action] + } + m.notification = fmt.Sprintf("✅ Partage %s avec succès!", actionName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.fileShareDetailView = nil + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/file"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 4167a1bf..e8a840a3 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -25,6 +25,7 @@ import ( "github.com/ovh/ovhcloud-cli/internal/flags" httpLib "github.com/ovh/ovhcloud-cli/internal/http" block_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/block_storage" + file_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/file_storage" "github.com/ovh/ovhcloud-cli/internal/services/browser/views" "github.com/spf13/cobra" ) @@ -332,6 +333,8 @@ type Model struct { detailRefreshName string // Block Storage detail view volumeDetailView *block_storage.DetailView + // File Storage detail view + fileShareDetailView *file_storage.DetailView } // Navigation items for the top bar @@ -959,12 +962,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case block_storage.ExecuteVolumeActionMsg: return m.handleExecuteVolumeAction(msg) + case file_storage.ExecuteFileShareActionMsg: + return m.handleExecuteFileShareAction(msg) + + case fileShareActionDoneMsg: + return m.handleFileShareActionDone(msg) + case views.GoBackMsg: if m.mode == DetailView && m.currentProduct == ProductStorageBlock { m.volumeDetailView = nil m.mode = TableView return m, nil } + if m.mode == DetailView && m.currentProduct == ProductStorageFile { + m.fileShareDetailView = nil + m.mode = TableView + return m, nil + } return m, nil case volumeActionDoneMsg: @@ -3912,9 +3926,14 @@ func (m Model) renderDetailView(width int) string { return m.volumeDetailView.Render(width, 0) } return m.renderGenericDetail(width) - default: - return m.renderGenericDetail(width) - } + case ProductStorageFile: + if m.fileShareDetailView != nil { + return m.fileShareDetailView.Render(width, 0) + } + return m.renderGenericDetail(width) + default: + return m.renderGenericDetail(width) + } } func (m Model) renderInstanceDetail(width int) string { @@ -4467,12 +4486,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } - navItems := getNavItems() - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit + // Delegate to file storage detail view when in DetailView for ProductStorageFile + if m.mode == DetailView && m.currentProduct == ProductStorageFile && m.fileShareDetailView != nil { + cmd := m.fileShareDetailView.HandleKey(msg) + return m, cmd + } - case "left": + switch msg.String() { + case "left": // In NodePoolDetailView, navigate actions if m.mode == NodePoolDetailView { if m.nodePoolDetailActionIdx > 0 { @@ -4727,6 +4748,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // If viewing a file storage share, init the detail view + if m.currentProduct == ProductStorageFile { + ctx := &views.Context{Width: m.width, Height: m.height} + m.fileShareDetailView = file_storage.NewDetailView(ctx, m.detailData) + return m, nil + } + // If viewing a Kubernetes cluster, also load node pools if m.currentProduct == ProductKubernetes { kubeId := getStringValue(m.detailData, "id", "") @@ -4739,6 +4767,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "up", "down", "j", "k": key := msg.String() + navItems := getNavItems() // ↓ on main nav over Stockage → enter sub-nav if (key == "down" || key == "j") && !m.inStorageSubNav && m.mode != DetailView && m.mode != ProjectSelectView && navItems[m.navIdx].Product == ProductStorage { @@ -4888,6 +4917,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil + + case "q", "ctrl+c": + return m, tea.Quit } return m, nil diff --git a/internal/services/browser/views/file_storage/detail.go b/internal/services/browser/views/file_storage/detail.go new file mode 100644 index 00000000..66cee624 --- /dev/null +++ b/internal/services/browser/views/file_storage/detail.go @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package file_storage + +import ( + "encoding/json" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +// Action indices for file share detail view +const ( + FileShareActionDelete = iota + FileShareActionRename + FileShareActionExtend +) + +var fileShareActionLabels = []string{"Delete", "Rename", "Extend"} + +// DetailView displays file storage share details with actions. +type DetailView struct { + views.BaseView + share map[string]interface{} + selectedAction int + confirmMode bool + renameMode bool + renameInput string + extendMode bool + extendInput string +} + +// NewDetailView creates a detail view for a file share. +func NewDetailView(ctx *views.Context, share map[string]interface{}) *DetailView { + return &DetailView{ + BaseView: views.NewBaseView(ctx), + share: share, + selectedAction: 0, + confirmMode: false, + } +} + +// Render displays the full detail panel. +func (v *DetailView) Render(width, height int) string { + var content strings.Builder + + if v.share == nil { + return views.StyleError.Render("No file share data available") + } + + id := getString(v.share, "id") + name := getString(v.share, "name") + status := getString(v.share, "status") + region := getString(v.share, "region") + shareType := getString(v.share, "type") + size := getSizeStr(v.share) + createdAt := getString(v.share, "createdAt") + if createdAt == "" { + createdAt = getString(v.share, "creationDate") + } + + var infoContent strings.Builder + infoContent.WriteString(views.RenderKeyValue("ID", id) + "\n") + infoContent.WriteString(views.RenderKeyValue("Nom", name) + "\n") + infoContent.WriteString(views.RenderKeyValue("Statut", views.RenderStatus(status)) + "\n") + infoContent.WriteString(views.RenderKeyValue("Région", region) + "\n") + infoContent.WriteString(views.RenderKeyValue("Type", shareType) + "\n") + infoContent.WriteString(views.RenderKeyValue("Capacité", size+" GB") + "\n") + if createdAt != "" { + infoContent.WriteString(views.RenderKeyValue("Créé le", createdAt) + "\n") + } + + content.WriteString(views.RenderBox("Informations du partage", infoContent.String(), width-4)) + content.WriteString("\n\n") + + actionsContent := v.renderActions() + content.WriteString(views.RenderBox("Actions (←/→ pour naviguer, Entrée pour exécuter)", actionsContent, width-4)) + + return content.String() +} + +func (v *DetailView) renderActions() string { + if v.renameMode { + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + return views.StyleStatusWarning.Render("Nouveau nom :") + "\n" + + inputStyle.Render(v.renameInput+"▌") + "\n\n" + + views.StyleFooter.Render("Entrée: Confirmer • Échap: Annuler") + } + + if v.extendMode { + currentSize := getSizeStr(v.share) + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(20) + return views.StyleStatusWarning.Render( + fmt.Sprintf("Nouvelle taille en GB (actuelle: %s GB, doit être supérieure) :", currentSize), + ) + "\n" + + inputStyle.Render(v.extendInput+"▌") + "\n\n" + + views.StyleFooter.Render("Entrée: Confirmer • Échap: Annuler") + } + + var parts []string + for i, label := range fileShareActionLabels { + var style lipgloss.Style + if i == v.selectedAction { + style = views.StyleButtonSelected + } else if label == "Delete" { + style = views.StyleButtonDanger + } else { + style = views.StyleButton + } + parts = append(parts, style.Render("["+label+"]")) + } + + result := strings.Join(parts, " ") + + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render( + fmt.Sprintf("⚠️ Appuyez sur Entrée pour confirmer %s, Échap pour annuler", + fileShareActionLabels[v.selectedAction])) + } + + return result +} + +// HandleKey processes keyboard input and returns a command. +func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + if v.renameMode { + switch msg.Type { + case tea.KeyEscape: + v.renameMode = false + v.renameInput = "" + case tea.KeyEnter: + if v.renameInput != "" { + name := v.renameInput + v.renameMode = false + v.renameInput = "" + return func() tea.Msg { + return ExecuteFileShareActionMsg{ + Share: v.share, + Action: FileShareActionRename, + Param: name, + } + } + } + case tea.KeyBackspace: + if len(v.renameInput) > 0 { + v.renameInput = v.renameInput[:len(v.renameInput)-1] + } + case tea.KeyRunes: + v.renameInput += string(msg.Runes) + } + return nil + } + + if v.extendMode { + switch msg.Type { + case tea.KeyEscape: + v.extendMode = false + v.extendInput = "" + case tea.KeyEnter: + if v.extendInput != "" { + newSize := v.extendInput + currentSizeStr := getSizeStr(v.share) + var newSizeInt, currentSizeInt int + fmt.Sscanf(newSize, "%d", &newSizeInt) + fmt.Sscanf(currentSizeStr, "%d", ¤tSizeInt) + if newSizeInt <= currentSizeInt { + v.extendInput = "" + return nil + } + v.extendMode = false + v.extendInput = "" + return func() tea.Msg { + return ExecuteFileShareActionMsg{ + Share: v.share, + Action: FileShareActionExtend, + Param: newSize, + } + } + } + case tea.KeyBackspace: + if len(v.extendInput) > 0 { + v.extendInput = v.extendInput[:len(v.extendInput)-1] + } + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + v.extendInput += string(r) + } + } + } + return nil + } + + switch key { + case "left": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + return nil + case "right": + if v.selectedAction < len(fileShareActionLabels)-1 { + v.selectedAction++ + v.confirmMode = false + } + return nil + case "enter": + if v.confirmMode { + v.confirmMode = false + return func() tea.Msg { + return ExecuteFileShareActionMsg{ + Share: v.share, + Action: v.selectedAction, + } + } + } + switch v.selectedAction { + case FileShareActionDelete: + v.confirmMode = true + case FileShareActionRename: + v.renameInput = getString(v.share, "name") + v.renameMode = true + case FileShareActionExtend: + v.extendInput = "" + v.extendMode = true + } + return nil + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + return func() tea.Msg { + return views.GoBackMsg{} + } + } + return nil +} + +// Title returns the header title. +func (v *DetailView) Title() string { + name := getString(v.share, "name") + return fmt.Sprintf(" 📁 File Storage > %s ", name) +} + +// HelpText returns the footer help text. +func (v *DetailView) HelpText() string { + if v.renameMode || v.extendMode { + return "Tapez la valeur • Entrée: Confirmer • Échap: Annuler" + } + if v.confirmMode { + return "Entrée: Confirmer l'action • Échap: Annuler" + } + return "←→: Sélectionner • Entrée: Exécuter • Échap: Retour à la liste • q: Quitter" +} + +// ExecuteFileShareActionMsg is dispatched when the user confirms an action. +type ExecuteFileShareActionMsg struct { + Share map[string]interface{} + Action int + Param string +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getSizeStr(share map[string]interface{}) string { + switch v := share["size"].(type) { + case float64: + return fmt.Sprintf("%d", int(v)) + case int: + return fmt.Sprintf("%d", v) + case json.Number: + if i, err := v.Int64(); err == nil { + return fmt.Sprintf("%d", i) + } + } + return "-" +} From 499463aab142174471b7b0b6f7ecb8dce4434808 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 09:48:40 +0000 Subject: [PATCH 11/55] feat(browser): added object storage but need fix Signed-off-by: olivier dubo --- internal/services/browser/api.go | 17 +- internal/services/browser/manager.go | 232 +++++++++- internal/services/browser/object_api.go | 314 +++++++++++++ internal/services/browser/object_wizard.go | 433 ++++++++++++++++++ .../browser/views/object_storage/detail.go | 221 +++++++++ 5 files changed, 1194 insertions(+), 23 deletions(-) create mode 100644 internal/services/browser/object_api.go create mode 100644 internal/services/browser/object_wizard.go create mode 100644 internal/services/browser/views/object_storage/detail.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index e612f67e..28e11700 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -88,6 +88,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/storage/object": + return func() tea.Msg { + msg := m.fetchS3StorageData() + msg.forProduct = product + return msg + } case "/networks/private": return func() tea.Msg { msg := m.fetchPrivateNetworksData() @@ -690,12 +696,15 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { } hasS3 := false + s3Offer := "Standard" for _, svc := range services { if svcMap, ok := svc.(map[string]interface{}); ok { if name, ok := svcMap["name"].(string); ok { - if name == "storage-s3-high-perf" || name == "storage-s3-standard" { + if name == "storage-s3-high-perf" { + hasS3 = true + s3Offer = "High Performance" + } else if name == "storage-s3-standard" { hasS3 = true - break } } } @@ -715,10 +724,12 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { var container map[string]interface{} detailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage/%s", m.cloudProject, regionName, containerName) if err := httpLib.Client.Get(detailEndpoint, &container); err == nil { + container["_offer"] = s3Offer allContainers = append(allContainers, container) } } else if containerObj, ok := item.(map[string]interface{}); ok { // It's already a full object + containerObj["_offer"] = s3Offer allContainers = append(allContainers, containerObj) } } @@ -1405,6 +1416,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createBlockStorageTable(msg.data, m.width, m.height) case ProductStorageFile: m.table = createFileStorageTable(msg.data, m.width, m.height) + case ProductStorageObject: + m.table = createObjectStorageTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index e8a840a3..c1a473ac 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -26,6 +26,7 @@ import ( httpLib "github.com/ovh/ovhcloud-cli/internal/http" block_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/block_storage" file_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/file_storage" + object_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/object_storage" "github.com/ovh/ovhcloud-cli/internal/services/browser/views" "github.com/spf13/cobra" ) @@ -109,6 +110,16 @@ const ( FileWizardStepSize FileWizardStepNetwork FileWizardStepConfirm + + ObjectWizardStepName WizardStep = iota + 500 + ObjectWizardStepType + ObjectWizardStepRegion + ObjectWizardStepReplication + ObjectWizardStepVersioning + ObjectWizardStepObjectLock + ObjectWizardStepUser + ObjectWizardStepEncryption + ObjectWizardStepConfirm ) // ProductType represents a product category @@ -286,6 +297,18 @@ type WizardData struct { fileShareSubnetCIDR string fileShareNetworkMenuIdx int // 0=network list, 1=subnet list fileShareConfirmBtnIdx int // 0=Create, 1=Cancel + // Object Storage wizard fields + objectName string // Container name + objectNameInput string + objectTypeIdx int // 0=Standard, 1=High Performance + objectRegions []string // Regions supporting S3 + objectUsers []map[string]interface{} // Cloud users + objectUserIdx int + objectReplication bool // Offsite replication enabled + objectVersioning bool // Versioning enabled + objectLock bool // Object Lock enabled + objectEncryption bool // Encryption enabled (AES256) + objectConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -335,6 +358,8 @@ type Model struct { volumeDetailView *block_storage.DetailView // File Storage detail view fileShareDetailView *file_storage.DetailView + // Object Storage detail view + objectDetailView *object_storage.DetailView } // Navigation items for the top bar @@ -639,7 +664,21 @@ type fileShareCreatedMsg struct { err error } -// Navigation items for products (shown after project is selected) +type objectStorageInitDataLoadedMsg struct { + regions []string + users []map[string]interface{} + err error +} + +type objectContainerCreatedMsg struct { + container map[string]interface{} + err error +} + +type objectContainerActionDoneMsg struct { + action int + err error +} func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -664,7 +703,7 @@ func getStorageSubItems() []StorageSubItem { {Label: "File Storage", Product: ProductStorageFile, Path: "/storage/file", Enabled: true}, {Label: "Volume Backup", Product: ProductStorageBackup, Path: "/storage/backup", Enabled: false}, {Label: "Volume Snapshot", Product: ProductStorageSnapshot, Path: "/storage/snapshot", Enabled: false}, - {Label: "Object Storage", Product: ProductStorageObject, Path: "/storage/object", Enabled: false}, + {Label: "Object Storage", Product: ProductStorageObject, Path: "/storage/object", Enabled: true}, } } @@ -805,6 +844,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Loading available regions...", } return m, m.fetchFileShareRegions() + } else if msg.product == ProductStorageObject { + m.mode = WizardView + m.wizard = WizardData{ + step: ObjectWizardStepName, + isLoading: true, + loadingMessage: "Loading regions and users...", + } + return m, m.fetchObjectStorageInitData() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -968,6 +1015,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileShareActionDoneMsg: return m.handleFileShareActionDone(msg) + case object_storage.ExecuteContainerActionMsg: + containerName := "" + region := "" + if msg.Container != nil { + if n, ok := msg.Container["name"].(string); ok { + containerName = n + } + if r, ok := msg.Container["region"].(string); ok { + region = r + } + } + m.notification = fmt.Sprintf("🗑️ Suppression du conteneur '%s'...", containerName) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.deleteObjectContainer(containerName, region) + case views.GoBackMsg: if m.mode == DetailView && m.currentProduct == ProductStorageBlock { m.volumeDetailView = nil @@ -979,6 +1041,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = TableView return m, nil } + if m.mode == DetailView && m.currentProduct == ProductStorageObject { + m.objectDetailView = nil + m.mode = TableView + return m, nil + } return m, nil case volumeActionDoneMsg: @@ -999,6 +1066,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileShareCreatedMsg: return m.handleFileShareCreated(msg) + case objectStorageInitDataLoadedMsg: + return m.handleObjectStorageInitDataLoaded(msg) + + case objectContainerCreatedMsg: + return m.handleObjectContainerCreated(msg) + + case objectContainerActionDoneMsg: + return m.handleObjectContainerActionDone(msg) + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -1220,7 +1296,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 400 { + if m.wizard.step >= 500 { + // Object Storage wizard + titleText = " 🪣 Create Object Storage Container " + } else if m.wizard.step >= 400 { // File Storage wizard titleText = " 🗂️ Create File Share " } else if m.wizard.step >= 300 { @@ -2131,7 +2210,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 400 { + if m.wizard.step >= 500 { + // Object Storage wizard + steps = append(steps, "Nom", "Type", "Région", "Réplication", "Versions", "Lock", "Utilisateur", "Chiffrement", "Confirmer") + stepMapping = append(stepMapping, ObjectWizardStepName, ObjectWizardStepType, ObjectWizardStepRegion, ObjectWizardStepReplication, ObjectWizardStepVersioning, ObjectWizardStepObjectLock, ObjectWizardStepUser, ObjectWizardStepEncryption, ObjectWizardStepConfirm) + } else if m.wizard.step >= 400 { // File Storage wizard steps = append(steps, "Name", "Region", "Type", "Size", "Network", "Confirm") stepMapping = append(stepMapping, FileWizardStepName, FileWizardStepRegion, FileWizardStepType, FileWizardStepSize, FileWizardStepNetwork, FileWizardStepConfirm) @@ -2277,18 +2360,31 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderFileWizardNetworkStep(width)) case FileWizardStepConfirm: content.WriteString(m.renderFileWizardConfirmStep(width)) + case ObjectWizardStepName: + content.WriteString(m.renderObjectWizardNameStep(width)) + case ObjectWizardStepType: + content.WriteString(m.renderObjectWizardTypeStep(width)) + case ObjectWizardStepRegion: + content.WriteString(m.renderObjectWizardRegionStep(width)) + case ObjectWizardStepReplication: + content.WriteString(m.renderObjectWizardReplicationStep(width)) + case ObjectWizardStepVersioning: + content.WriteString(m.renderObjectWizardVersioningStep(width)) + case ObjectWizardStepObjectLock: + content.WriteString(m.renderObjectWizardObjectLockStep(width)) + case ObjectWizardStepUser: + content.WriteString(m.renderObjectWizardUserStep(width)) + case ObjectWizardStepEncryption: + content.WriteString(m.renderObjectWizardEncryptionStep(width)) + case ObjectWizardStepConfirm: + content.WriteString(m.renderObjectWizardConfirmStep(width)) } - return content.String() } -// renderWizardRegionStep renders the region selection step func (m Model) renderWizardRegionStep(width int) string { var content strings.Builder - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Select a region:") + "\n") - // Show filter input if active if m.wizard.filterMode { filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")) @@ -3839,6 +3935,8 @@ func (m Model) getProductCreationInfo() (string, string) { return "block storage volumes", "" case ProductStorageFile: return "file shares", "" + case ProductStorageObject: + return "object storage containers", "" case ProductNetworks: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) default: @@ -3931,6 +4029,11 @@ func (m Model) renderDetailView(width int) string { return m.fileShareDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductStorageObject: + if m.objectDetailView != nil { + return m.objectDetailView.Render(width, 0) + } + return m.renderGenericDetail(width) default: return m.renderGenericDetail(width) } @@ -4492,6 +4595,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + // Delegate to object storage detail view when in DetailView for ProductStorageObject + if m.mode == DetailView && m.currentProduct == ProductStorageObject && m.objectDetailView != nil { + cmd := m.objectDetailView.HandleKey(msg) + return m, cmd + } + switch msg.String() { case "left": // In NodePoolDetailView, navigate actions @@ -4755,6 +4864,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // If viewing an object storage container, init the detail view + if m.currentProduct == ProductStorageObject { + ctx := &views.Context{Width: m.width, Height: m.height} + m.objectDetailView = object_storage.NewDetailView(ctx, m.detailData) + return m, nil + } + // If viewing a Kubernetes cluster, also load node pools if m.currentProduct == ProductKubernetes { kubeId := getStringValue(m.detailData, "id", "") @@ -5059,6 +5175,12 @@ func (m *Model) applyTableFilter() { m.table = createInstancesTable(m.currentData, m.imageMap, m.floatingIPMap, m.width, m.height) case ProductKubernetes: m.table = createKubernetesTable(m.currentData, m.width, m.height) + case ProductStorageBlock: + m.table = createBlockStorageTable(m.currentData, m.width, m.height) + case ProductStorageFile: + m.table = createFileStorageTable(m.currentData, m.width, m.height) + case ProductStorageObject: + m.table = createObjectStorageTable(m.currentData, m.width, m.height) default: m.table = createGenericTable(m.currentData, m.width, m.height) } @@ -5091,6 +5213,16 @@ func (m *Model) applyTableFilter() { } } m.table = createKubernetesTable(filtered, m.width, m.height) + case ProductStorageObject: + var filtered []map[string]interface{} + for _, item := range m.currentData { + name := strings.ToLower(getStringValue(item, "name", "")) + region := strings.ToLower(getStringValue(item, "region", "")) + if strings.Contains(name, filter) || strings.Contains(region, filter) { + filtered = append(filtered, item) + } + } + m.table = createObjectStorageTable(filtered, m.width, m.height) default: m.table = createGenericTable(m.currentData, m.width, m.height) } @@ -5100,6 +5232,14 @@ func (m *Model) applyTableFilter() { func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + // Block all key input while an async operation is in progress. + if m.wizard.isLoading { + if key == "ctrl+c" { + return m, tea.Quit + } + return m, nil + } + // Handle cleanup confirmation mode if m.wizard.cleanupPending { return m.handleCleanupConfirmKeys(key) @@ -5135,7 +5275,9 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 400 { + if m.wizard.step >= 500 { + returnPath = "/storage/object" + } else if m.wizard.step >= 400 { returnPath = "/storage/file" } else if m.wizard.step >= 300 { // Volume wizard @@ -5222,23 +5364,71 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFileWizardNetworkKeys(key, msg) case FileWizardStepConfirm: return m.handleFileWizardConfirmKeys(key) + // Object Storage wizard steps + case ObjectWizardStepName: + return m.handleObjectWizardNameKeys(msg) + case ObjectWizardStepType: + return m.handleObjectWizardTypeKeys(key) + case ObjectWizardStepRegion: + return m.handleObjectWizardRegionKeys(key) + case ObjectWizardStepReplication: + switch key { + case "left", "h", "y": + m.wizard.objectReplication = true + case "right", "l", "n": + m.wizard.objectReplication = false + case "enter": + m.wizard.step = ObjectWizardStepVersioning + case "esc": + m.wizard.step = ObjectWizardStepRegion + } + return m, nil + case ObjectWizardStepVersioning: + switch key { + case "left", "h", "y": + m.wizard.objectVersioning = true + case "right", "l", "n": + m.wizard.objectVersioning = false + case "enter": + m.wizard.step = ObjectWizardStepObjectLock + case "esc": + m.wizard.step = ObjectWizardStepReplication + } + return m, nil + case ObjectWizardStepObjectLock: + switch key { + case "left", "h", "y": + m.wizard.objectLock = true + case "right", "l", "n": + m.wizard.objectLock = false + case "enter": + m.wizard.step = ObjectWizardStepUser + case "esc": + m.wizard.step = ObjectWizardStepVersioning + } + return m, nil + case ObjectWizardStepUser: + return m.handleObjectWizardUserKeys(key) + case ObjectWizardStepEncryption: + switch key { + case "left", "h", "y": + m.wizard.objectEncryption = true + case "right", "l", "n": + m.wizard.objectEncryption = false + case "enter": + m.wizard.step = ObjectWizardStepConfirm + case "esc": + m.wizard.step = ObjectWizardStepUser + } + return m, nil + case ObjectWizardStepConfirm: + return m.handleObjectWizardConfirmKeys(key) } - return m, nil } -// handleCleanupConfirmKeys handles key presses in cleanup confirmation mode func (m Model) handleCleanupConfirmKeys(key string) (tea.Model, tea.Cmd) { switch key { - case "left", "right": - // Toggle between Yes and No - if m.wizard.selectedIndex == 0 { - m.wizard.selectedIndex = 1 - } else { - m.wizard.selectedIndex = 0 - } - return m, nil - case "enter": if m.wizard.selectedIndex == 0 { // Yes, delete all - start cleanup diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go new file mode 100644 index 00000000..288578b7 --- /dev/null +++ b/internal/services/browser/object_api.go @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net/url" + "sort" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" + object_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/object_storage" +) + +// ─── Object Storage API ─────────────────────────────────────────────────────── + +// fetchObjectStorageInitData loads S3-capable regions and cloud users concurrently. +func (m Model) fetchObjectStorageInitData() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return objectStorageInitDataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + + // Fetch region names + var regionNames []string + if err := httpLib.Client.Get(fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject), ®ionNames); err != nil { + return objectStorageInitDataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + + // Probe regions concurrently for S3 support + type probeResult struct { + region string + supported bool + } + ch := make(chan probeResult, len(regionNames)) + for _, name := range regionNames { + go func(r string) { + var region map[string]interface{} + ep := fmt.Sprintf("/v1/cloud/project/%s/region/%s", m.cloudProject, url.PathEscape(r)) + if err := httpLib.Client.Get(ep, ®ion); err != nil { + ch <- probeResult{region: r, supported: false} + return + } + services, _ := region["services"].([]interface{}) + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if n, _ := sm["name"].(string); n == "storage-s3-high-perf" || n == "storage-s3-standard" { + ch <- probeResult{region: r, supported: true} + return + } + } + } + ch <- probeResult{region: r, supported: false} + }(name) + } + + var supportedRegions []string + for range regionNames { + r := <-ch + if r.supported { + supportedRegions = append(supportedRegions, r.region) + } + } + sort.Strings(supportedRegions) + + // Fetch cloud users + var users []map[string]interface{} + userEndpoint := fmt.Sprintf("/v1/cloud/project/%s/user", m.cloudProject) + if err := httpLib.Client.Get(userEndpoint, &users); err != nil { + // Non-fatal: continue without users + users = nil + } + sort.Slice(users, func(i, j int) bool { + iName, _ := users[i]["username"].(string) + jName, _ := users[j]["username"].(string) + return iName < jName + }) + + if len(supportedRegions) == 0 { + return objectStorageInitDataLoadedMsg{err: fmt.Errorf("no regions support object storage in this project")} + } + return objectStorageInitDataLoadedMsg{regions: supportedRegions, users: users} + } +} + +// createObjectContainer creates a new S3 container via the OVH API. +func (m Model) createObjectContainer() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return objectContainerCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + + body := map[string]interface{}{ + "name": m.wizard.objectName, + } + + // Encryption + if m.wizard.objectEncryption { + body["encryption"] = map[string]interface{}{ + "sseAlgorithm": "AES256", + } + } + + // ObjectLock requires versioning; enable both if lock is requested. + enableVersioning := m.wizard.objectVersioning || m.wizard.objectLock + + // Versioning + if enableVersioning { + body["versioning"] = map[string]interface{}{ + "status": "enabled", + } + } + + // Object Lock (requires versioning) + if m.wizard.objectLock { + body["objectLock"] = map[string]interface{}{ + "status": "enabled", + } + } + + // Owner (user) + if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { + user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] + if ownerId, ok := user["id"]; ok { + switch v := ownerId.(type) { + case float64: + body["ownerId"] = int(v) + case int: + body["ownerId"] = v + } + } + } + + // Container type (storageClass in name? No — it's a separate field per region). + // Type is encoded in the region selection (High Perf vs Standard regions). + + region := m.wizard.selectedRegion + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage", + m.cloudProject, url.PathEscape(region)) + + var container map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &container); err != nil { + return objectContainerCreatedMsg{err: fmt.Errorf("failed to create container: %w", err)} + } + return objectContainerCreatedMsg{container: container} + } +} + +// deleteObjectContainer deletes an S3 container. +func (m Model) deleteObjectContainer(containerName, region string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(containerName)) + err := httpLib.Client.Delete(endpoint, nil) + return objectContainerActionDoneMsg{action: object_storage.ContainerActionDelete, err: err} + } +} + +// ─── Object Storage message handlers ───────────────────────────────────────── + +func (m Model) handleObjectStorageInitDataLoaded(msg objectStorageInitDataLoadedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.objectRegions = msg.regions + m.wizard.objectUsers = msg.users + m.wizard.selectedIndex = 0 + return m, nil +} + +func (m Model) handleObjectContainerCreated(msg objectContainerCreatedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + name := getString(msg.container, "name") + if name == "" { + name = "container" + } + m.notification = fmt.Sprintf("✅ Container '%s' created successfully!", name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.wizard = WizardData{} + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + +func (m Model) handleObjectContainerActionDone(msg objectContainerActionDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Action échouée: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + m.notification = "✅ Container supprimé avec succès!" + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.objectDetailView = nil + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + +// createObjectStorageTable builds the table for S3 containers. +func createObjectStorageTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 28}, + {Title: "Localisation", Width: 12}, + {Title: "Mode de déploiement", Width: 22}, + {Title: "Offre", Width: 16}, + {Title: "Nbr objets", Width: 11}, + {Title: "Espace utilisé", Width: 14}, + {Title: "Type", Width: 10}, + } + + var rows []table.Row + for _, c := range data { + name := getString(c, "name") + region := getString(c, "region") + + // Mode de déploiement: derived from virtualHost presence + virtualHost := getString(c, "virtualHost") + deployMode := "Multi-AZ" + if virtualHost == "" { + deployMode = "Single-AZ" + } + + // Offre: injected by fetchS3StorageData from the region service name + offer := getString(c, "_offer") + if offer == "" { + offer = "Standard" + } + + objectsCount := "-" + if v, ok := c["objectsCount"]; ok { + switch n := v.(type) { + case float64: + objectsCount = fmt.Sprintf("%d", int(n)) + } + } + + sizeStr := "-" + if v, ok := c["objectsSize"]; ok { + switch n := v.(type) { + case float64: + if n < 1024 { + sizeStr = fmt.Sprintf("%.0f B", n) + } else if n < 1024*1024 { + sizeStr = fmt.Sprintf("%.1f KB", n/1024) + } else if n < 1024*1024*1024 { + sizeStr = fmt.Sprintf("%.1f MB", n/1024/1024) + } else { + sizeStr = fmt.Sprintf("%.2f GB", n/1024/1024/1024) + } + } + } + + // Type: from the container's own type field if present + containerType := getString(c, "type") + if containerType == "" { + containerType = "-" + } + + rows = append(rows, table.Row{name, region, deployMode, offer, objectsCount, sizeStr, containerType}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + return t +} diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go new file mode 100644 index 00000000..073af8ca --- /dev/null +++ b/internal/services/browser/object_wizard.go @@ -0,0 +1,433 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ─── Object Storage wizard render functions ─────────────────────────────────── + +var objectContainerTypes = []string{"Standard", "High Performance"} + +func (m Model) renderObjectWizardNameStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7B68EE")). + Padding(0, 1). + Width(40) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("🪣 Nom du conteneur :") + "\n\n") + content.WriteString(inputStyle.Render(m.wizard.objectNameInput+"▌") + "\n\n") + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) + content.WriteString(errStyle.Render("⚠ "+m.wizard.errorMsg) + "\n\n") + } else { + content.WriteString(hintStyle.Render("Lettres minuscules, chiffres et tirets uniquement (3-63 car).") + "\n\n") + } + content.WriteString(hintStyle.Render("Entrée: Suivant • Échap: Annuler")) + return content.String() +} + +func (m Model) renderObjectWizardTypeStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("📦 Type de conteneur :") + "\n\n") + for i, t := range objectContainerTypes { + if i == m.wizard.objectTypeIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", t)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", t)) + "\n") + } + } + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + return content.String() +} + +func (m Model) renderObjectWizardRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("🌍 Localisation (région) :") + "\n\n") + if len(m.wizard.objectRegions) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Aucune région disponible.") + "\n") + } else { + maxVisible := 10 + startIdx := 0 + if m.wizard.selectedIndex >= maxVisible { + startIdx = m.wizard.selectedIndex - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.wizard.objectRegions) { + endIdx = len(m.wizard.objectRegions) + } + if startIdx > 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" ↑ more") + "\n") + } + for i := startIdx; i < endIdx; i++ { + r := m.wizard.objectRegions[i] + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", r)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", r)) + "\n") + } + } + if endIdx < len(m.wizard.objectRegions) { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" ↓ more") + "\n") + } + } + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + return content.String() +} + +func (m Model) renderObjectWizardReplicationStep(width int) string { + return renderObjectToggleStep("🔄 Réplication hors site (Offsite Replication) :", + "Répliquer les objets dans une autre zone géographique automatiquement.", + m.wizard.objectReplication) +} + +func (m Model) renderObjectWizardVersioningStep(width int) string { + return renderObjectToggleStep("📂 Gestion des versions :", + "Conserver plusieurs versions de chaque objet (nécessaire pour Object Lock).", + m.wizard.objectVersioning) +} + +func (m Model) renderObjectWizardObjectLockStep(width int) string { + return renderObjectToggleStep("🔒 Object Lock (WORM) :", + "Empêcher la suppression ou modification des objets pendant une période définie.", + m.wizard.objectLock) +} + +func (m Model) renderObjectWizardEncryptionStep(width int) string { + return renderObjectToggleStep("🔐 Chiffrement côté serveur (AES-256) :", + "Chiffrer automatiquement tous les objets stockés dans ce conteneur.", + m.wizard.objectEncryption) +} + +func renderObjectToggleStep(title, description string, enabled bool) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render(title) + "\n\n") + content.WriteString(descStyle.Render(description) + "\n\n") + + onStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) + offStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Padding(0, 2) + selectedBorder := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#00FF7F")).Padding(0, 1) + normalBorder := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1) + + var onBtn, offBtn string + if enabled { + onBtn = selectedBorder.Render(onStyle.Render("✓ Activé")) + offBtn = normalBorder.Render(offStyle.Render(" Désactivé")) + } else { + onBtn = normalBorder.Render(offStyle.Render(" Activé")) + offBtn = selectedBorder.Render(onStyle.Render("✗ Désactivé")) + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, onBtn, " ", offBtn)) + content.WriteString("\n\n") + content.WriteString(hintStyle.Render("←→ ou y/n: Basculer • Entrée: Suivant • Échap: Retour")) + return content.String() +} + +func (m Model) renderObjectWizardUserStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("👤 Utilisateur propriétaire :") + "\n\n") + + // First option: no user (project-level) + if m.wizard.objectUserIdx == 0 { + content.WriteString(selectedStyle.Render(" ▶ [Aucun utilisateur spécifique]") + "\n") + } else { + content.WriteString(itemStyle.Render(" [Aucun utilisateur spécifique]") + "\n") + } + + for i, user := range m.wizard.objectUsers { + username, _ := user["username"].(string) + if username == "" { + if desc, ok := user["description"].(string); ok { + username = desc + } else { + username = fmt.Sprintf("user-%d", i+1) + } + } + idx := i + 1 + if idx == m.wizard.objectUserIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", username)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", username)) + "\n") + } + } + + if len(m.wizard.objectUsers) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" (aucun utilisateur trouvé)") + "\n") + } + + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + return content.String() +} + +func (m Model) renderObjectWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).Bold(true) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(titleStyle.Render("✅ Résumé du conteneur :") + "\n\n") + content.WriteString(labelStyle.Render(" Nom : ") + valueStyle.Render(m.wizard.objectName) + "\n") + + typeName := "Standard" + if m.wizard.objectTypeIdx == 1 { + typeName = "High Performance" + } + content.WriteString(labelStyle.Render(" Type : ") + valueStyle.Render(typeName) + "\n") + + region := m.wizard.selectedRegion + if region == "" && len(m.wizard.objectRegions) > 0 { + region = m.wizard.objectRegions[0] + } + content.WriteString(labelStyle.Render(" Région : ") + valueStyle.Render(region) + "\n") + content.WriteString(labelStyle.Render(" Réplication : ") + valueStyle.Render(boolToFrench(m.wizard.objectReplication)) + "\n") + content.WriteString(labelStyle.Render(" Versioning : ") + valueStyle.Render(boolToFrench(m.wizard.objectVersioning)) + "\n") + content.WriteString(labelStyle.Render(" Object Lock : ") + valueStyle.Render(boolToFrench(m.wizard.objectLock)) + "\n") + content.WriteString(labelStyle.Render(" Chiffrement : ") + valueStyle.Render(boolToFrench(m.wizard.objectEncryption)) + "\n") + + if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { + user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] + username, _ := user["username"].(string) + content.WriteString(labelStyle.Render(" Utilisateur : ") + valueStyle.Render(username) + "\n") + } else { + content.WriteString(labelStyle.Render(" Utilisateur : ") + valueStyle.Render("(aucun)") + "\n") + } + + content.WriteString("\n") + + createStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#00AA55")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 2) + cancelStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#555555")). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 2) + + var createBtn, cancelBtn string + if m.wizard.objectConfirmBtnIdx == 0 { + createBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#00FF7F")).Render(createStyle.Render(" ▶ [Créer]")) + cancelBtn = lipgloss.NewStyle().Padding(1).Render(cancelStyle.Render(" [Annuler]")) + } else { + createBtn = lipgloss.NewStyle().Padding(1).Render(createStyle.Render(" [Créer]")) + cancelBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#FF6B6B")).Render(cancelStyle.Render(" ▶ [Annuler]")) + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, createBtn, " ", cancelBtn)) + content.WriteString("\n\n") + if m.wizard.errorMsg != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) + content.WriteString(errStyle.Render("⚠ Erreur: "+m.wizard.errorMsg) + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Basculer • Entrée: Réessayer • N: Changer le nom • Échap: Retour")) + } else { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Basculer • Entrée: Confirmer • Échap: Retour")) + } + return content.String() +} + +func boolToFrench(v bool) string { + if v { + return "Activé" + } + return "Désactivé" +} + +// ─── Object Storage wizard key handlers ────────────────────────────────────── + +func (m Model) handleObjectWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + name := strings.TrimSpace(m.wizard.objectNameInput) + if name == "" { + return m, nil + } + // Enforce S3 bucket naming rules + if len(name) < 3 || len(name) > 63 { + m.wizard.errorMsg = "Le nom doit contenir entre 3 et 63 caractères." + return m, nil + } + for _, ch := range name { + if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') { + m.wizard.errorMsg = "Uniquement lettres minuscules, chiffres et tirets (-). Pas de majuscules ni espaces." + return m, nil + } + } + if name[0] == '-' || name[len(name)-1] == '-' { + m.wizard.errorMsg = "Le nom ne peut pas commencer ou finir par un tiret." + return m, nil + } + m.wizard.errorMsg = "" + m.wizard.objectName = name + m.wizard.step = ObjectWizardStepType + m.wizard.selectedIndex = m.wizard.objectTypeIdx + return m, nil + case tea.KeyEscape: + m.mode = TableView + m.wizard = WizardData{} + return m, nil + case tea.KeyBackspace: + if len(m.wizard.objectNameInput) > 0 { + m.wizard.objectNameInput = m.wizard.objectNameInput[:len(m.wizard.objectNameInput)-1] + } + case tea.KeyRunes: + m.wizard.objectNameInput += string(msg.Runes) + } + return m, nil +} + +func (m Model) handleObjectWizardTypeKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.objectTypeIdx > 0 { + m.wizard.objectTypeIdx-- + } + case "down", "j": + if m.wizard.objectTypeIdx < len(objectContainerTypes)-1 { + m.wizard.objectTypeIdx++ + } + case "enter": + m.wizard.step = ObjectWizardStepRegion + m.wizard.selectedIndex = 0 + case "esc": + m.wizard.step = ObjectWizardStepName + } + return m, nil +} + +func (m Model) handleObjectWizardRegionKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(m.wizard.objectRegions)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(m.wizard.objectRegions) > 0 { + m.wizard.selectedRegion = m.wizard.objectRegions[m.wizard.selectedIndex] + } + m.wizard.step = ObjectWizardStepReplication + case "esc": + m.wizard.step = ObjectWizardStepType + } + return m, nil +} + +// handleObjectWizardToggleKeys handles yes/no toggle steps. +// nextStep is the step to advance to on Enter. +// field is a pointer to the bool to toggle. +func (m Model) handleObjectWizardToggleKeys(key string, nextStep WizardStep, field *bool) (tea.Model, tea.Cmd) { + switch key { + case "left", "h", "y": + *field = true + case "right", "l", "n": + *field = false + case "enter": + m.wizard.step = nextStep + case "esc": + m.wizard.step = prevObjectStep(m.wizard.step) + } + return m, nil +} + +func prevObjectStep(step WizardStep) WizardStep { + switch step { + case ObjectWizardStepReplication: + return ObjectWizardStepRegion + case ObjectWizardStepVersioning: + return ObjectWizardStepReplication + case ObjectWizardStepObjectLock: + return ObjectWizardStepVersioning + case ObjectWizardStepUser: + return ObjectWizardStepObjectLock + case ObjectWizardStepEncryption: + return ObjectWizardStepUser + default: + return ObjectWizardStepReplication + } +} + +func (m Model) handleObjectWizardUserKeys(key string) (tea.Model, tea.Cmd) { + totalItems := 1 + len(m.wizard.objectUsers) // 0 = no user, 1..N = users + switch key { + case "up", "k": + if m.wizard.objectUserIdx > 0 { + m.wizard.objectUserIdx-- + } + case "down", "j": + if m.wizard.objectUserIdx < totalItems-1 { + m.wizard.objectUserIdx++ + } + case "enter": + m.wizard.step = ObjectWizardStepEncryption + case "esc": + m.wizard.step = ObjectWizardStepObjectLock + } + return m, nil +} + +func (m Model) handleObjectWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.objectConfirmBtnIdx = 0 + case "right", "l": + m.wizard.objectConfirmBtnIdx = 1 + case "n": + // Shortcut to go back to name step (useful when there's a name conflict error) + m.wizard.errorMsg = "" + m.wizard.step = ObjectWizardStepName + return m, nil + case "enter": + if m.wizard.objectConfirmBtnIdx == 1 { + // Cancel + m.mode = TableView + m.wizard = WizardData{} + return m, nil + } + // Clear previous error before retrying + m.wizard.errorMsg = "" + // Create + m.wizard.isLoading = true + m.wizard.loadingMessage = fmt.Sprintf("Création du conteneur '%s'...", m.wizard.objectName) + return m, m.createObjectContainer() + case "esc": + m.wizard.step = ObjectWizardStepEncryption + } + return m, nil +} diff --git a/internal/services/browser/views/object_storage/detail.go b/internal/services/browser/views/object_storage/detail.go new file mode 100644 index 00000000..817f52b6 --- /dev/null +++ b/internal/services/browser/views/object_storage/detail.go @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package object_storage + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +// Action indices for object storage container detail view +const ( + ContainerActionDelete = iota +) + +var containerActionLabels = []string{"Delete"} + +// DetailView displays object storage container details with actions. +type DetailView struct { + views.BaseView + container map[string]interface{} + selectedAction int + confirmMode bool +} + +// NewDetailView creates a detail view for an S3 container. +func NewDetailView(ctx *views.Context, container map[string]interface{}) *DetailView { + return &DetailView{ + BaseView: views.NewBaseView(ctx), + container: container, + selectedAction: 0, + confirmMode: false, + } +} + +// Render displays the full detail panel. +func (v *DetailView) Render(width, height int) string { + var content strings.Builder + + if v.container == nil { + return views.StyleError.Render("No container data available") + } + + name := getString(v.container, "name") + region := getString(v.container, "region") + createdAt := getString(v.container, "createdAt") + if createdAt == "" { + createdAt = "-" + } + + // Versioning + versioningStatus := "-" + if vers, ok := v.container["versioning"].(map[string]interface{}); ok { + if s, ok := vers["status"].(string); ok { + versioningStatus = s + } + } + + // Encryption + encryptionStatus := "Désactivé" + encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + if enc, ok := v.container["encryption"].(map[string]interface{}); ok { + if alg, _ := enc["sseAlgorithm"].(string); alg != "" { + encryptionStatus = "Actif (" + alg + ")" + encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + } + } + + // Object lock + objectLockStatus := "-" + if ol, ok := v.container["objectLock"].(map[string]interface{}); ok { + if s, _ := ol["status"].(string); s != "" { + objectLockStatus = s + } + } + + // Objects count and size + objectsCount := "-" + if v, ok := v.container["objectsCount"]; ok { + switch n := v.(type) { + case float64: + objectsCount = fmt.Sprintf("%d", int(n)) + } + } + + sizeStr := "-" + if sz, ok := v.container["objectsSize"]; ok { + switch n := sz.(type) { + case float64: + if n < 1024 { + sizeStr = fmt.Sprintf("%.0f B", n) + } else if n < 1024*1024 { + sizeStr = fmt.Sprintf("%.1f KB", n/1024) + } else if n < 1024*1024*1024 { + sizeStr = fmt.Sprintf("%.1f MB", n/1024/1024) + } else { + sizeStr = fmt.Sprintf("%.2f GB", n/1024/1024/1024) + } + } + } + + var infoContent strings.Builder + infoContent.WriteString(views.RenderKeyValue("Nom", name) + "\n") + infoContent.WriteString(views.RenderKeyValue("Région", region) + "\n") + infoContent.WriteString(views.RenderKeyValue("Créé le", createdAt) + "\n") + infoContent.WriteString(views.RenderKeyValue("Objets", objectsCount) + "\n") + infoContent.WriteString(views.RenderKeyValue("Taille totale", sizeStr) + "\n") + infoContent.WriteString(views.RenderKeyValue("Versioning", versioningStatus) + "\n") + infoContent.WriteString(views.StyleLabel.Render("Chiffrement:") + " " + encryptionStyle.Render(encryptionStatus) + "\n") + infoContent.WriteString(views.RenderKeyValue("Object Lock", objectLockStatus) + "\n") + + content.WriteString(views.RenderBox("Informations du conteneur", infoContent.String(), width-4)) + content.WriteString("\n\n") + + actionsContent := v.renderActions() + content.WriteString(views.RenderBox("Actions (←/→ pour naviguer, Entrée pour exécuter)", actionsContent, width-4)) + + return content.String() +} + +func (v *DetailView) renderActions() string { + var parts []string + for i, label := range containerActionLabels { + var style lipgloss.Style + if i == v.selectedAction { + style = views.StyleButtonSelected + } else if label == "Delete" { + style = views.StyleButtonDanger + } else { + style = views.StyleButton + } + parts = append(parts, style.Render("["+label+"]")) + } + + result := strings.Join(parts, " ") + + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render( + "⚠️ Appuyez sur Entrée pour confirmer la suppression, Échap pour annuler") + } + + return result +} + +// HandleKey processes keyboard input and returns a command. +func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + switch key { + case "left": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + return nil + case "right": + if v.selectedAction < len(containerActionLabels)-1 { + v.selectedAction++ + v.confirmMode = false + } + return nil + case "enter": + if v.confirmMode { + v.confirmMode = false + return func() tea.Msg { + return ExecuteContainerActionMsg{ + Container: v.container, + Action: v.selectedAction, + } + } + } + switch v.selectedAction { + case ContainerActionDelete: + v.confirmMode = true + } + return nil + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + return func() tea.Msg { + return views.GoBackMsg{} + } + } + return nil +} + +// Title returns the header title. +func (v *DetailView) Title() string { + name := getString(v.container, "name") + return fmt.Sprintf(" 🪣 Object Storage > %s ", name) +} + +// HelpText returns the footer help text. +func (v *DetailView) HelpText() string { + if v.confirmMode { + return "Entrée: Confirmer la suppression • Échap: Annuler" + } + return "←→: Sélectionner • Entrée: Exécuter • Échap: Retour à la liste • q: Quitter" +} + +// ExecuteContainerActionMsg is dispatched when the user confirms an action. +type ExecuteContainerActionMsg struct { + Container map[string]interface{} + Action int +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} From bd02a2e84cd9a05a886d7f4532691582e3bc36e3 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 12:46:49 +0000 Subject: [PATCH 12/55] feat(browser): fixed name french to english Signed-off-by: olivier dubo --- internal/services/browser/file_api.go | 18 ++-- internal/services/browser/manager.go | 4 +- internal/services/browser/object_api.go | 16 ++-- internal/services/browser/object_wizard.go | 84 +++++++++---------- .../browser/views/file_storage/detail.go | 32 +++---- .../browser/views/object_storage/detail.go | 26 +++--- 6 files changed, 90 insertions(+), 90 deletions(-) diff --git a/internal/services/browser/file_api.go b/internal/services/browser/file_api.go index bf39278c..66aaacf2 100644 --- a/internal/services/browser/file_api.go +++ b/internal/services/browser/file_api.go @@ -198,12 +198,12 @@ func (m Model) fetchFileStorageData() dataLoadedMsg { // createFileStorageTable builds the table model for file shares. func createFileStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ - {Title: "Nom", Width: 25}, + {Title: "Name", Width: 25}, {Title: "ID", Width: 20}, - {Title: "Région", Width: 12}, + {Title: "Region", Width: 12}, {Title: "Type", Width: 16}, {Title: "Capacité", Width: 12}, - {Title: "Statut", Width: 12}, + {Title: "Status", Width: 12}, } var rows []table.Row @@ -366,21 +366,21 @@ func (m Model) handleExecuteFileShareAction(msg file_storage.ExecuteFileShareAct switch msg.Action { case file_storage.FileShareActionDelete: - m.notification = fmt.Sprintf("🗑️ Suppression du partage '%s'...", shareName) + m.notification = fmt.Sprintf("🗑️ Deleting share '%s'...", shareName) m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.deleteFileShare(shareId, region) case file_storage.FileShareActionRename: - m.notification = "✏️ Renommage du partage..." + m.notification = "✏️ Renaming share..." m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.renameFileShare(shareId, region, msg.Param) case file_storage.FileShareActionExtend: newSize, err := strconv.Atoi(msg.Param) if err != nil || newSize < 1 { - m.notification = "❌ Taille invalide" + m.notification = "❌ Invalid size" m.notificationExpiry = time.Now().Add(5 * time.Second) return m, nil } - m.notification = fmt.Sprintf("⬆️ Extension du partage à %d GB...", newSize) + m.notification = fmt.Sprintf("⬆️ Extending share to %d GB...", newSize) m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.extendFileShare(shareId, region, newSize) } @@ -389,14 +389,14 @@ func (m Model) handleExecuteFileShareAction(msg file_storage.ExecuteFileShareAct func (m Model) handleFileShareActionDone(msg fileShareActionDoneMsg) (tea.Model, tea.Cmd) { if msg.err != nil { - m.notification = fmt.Sprintf("❌ Action échouée: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Action failed: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - actionNames := []string{"supprimé", "renommé", "étendu"} + actionNames := []string{"deleted", "renamed", "extended"} actionName := "mis à jour" if msg.action >= 0 && msg.action < len(actionNames) { actionName = actionNames[msg.action] diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index c1a473ac..81f45e70 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -685,7 +685,7 @@ func getNavItems() []NavItem { {Label: " Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, - {Label: "Stockage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, + {Label: "Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, {Label: "Private networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, } } @@ -1026,7 +1026,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { region = r } } - m.notification = fmt.Sprintf("🗑️ Suppression du conteneur '%s'...", containerName) + m.notification = fmt.Sprintf("🗑️ Deleting container '%s'...", containerName) m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.deleteObjectContainer(containerName, region) diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index 288578b7..ad242084 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -203,13 +203,13 @@ func (m Model) handleObjectContainerCreated(msg objectContainerCreatedMsg) (tea. func (m Model) handleObjectContainerActionDone(msg objectContainerActionDoneMsg) (tea.Model, tea.Cmd) { if msg.err != nil { - m.notification = fmt.Sprintf("❌ Action échouée: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Action failed: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - m.notification = "✅ Container supprimé avec succès!" + m.notification = "✅ Container deleted successfully!" m.notificationExpiry = time.Now().Add(5 * time.Second) m.objectDetailView = nil m.detailData = nil @@ -225,12 +225,12 @@ func (m Model) handleObjectContainerActionDone(msg objectContainerActionDoneMsg) // createObjectStorageTable builds the table for S3 containers. func createObjectStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ - {Title: "Nom", Width: 28}, - {Title: "Localisation", Width: 12}, - {Title: "Mode de déploiement", Width: 22}, - {Title: "Offre", Width: 16}, - {Title: "Nbr objets", Width: 11}, - {Title: "Espace utilisé", Width: 14}, + {Title: "Name", Width: 28}, + {Title: "Location", Width: 12}, + {Title: "Deployment mode", Width: 22}, + {Title: "Offer", Width: 16}, + {Title: "Objects", Width: 11}, + {Title: "Used space", Width: 14}, {Title: "Type", Width: 10}, } diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index 073af8ca..935a9527 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -28,7 +28,7 @@ func (m Model) renderObjectWizardNameStep(width int) string { Width(40) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("🪣 Nom du conteneur :") + "\n\n") + content.WriteString(titleStyle.Render("🪣 Container name:") + "\n\n") content.WriteString(inputStyle.Render(m.wizard.objectNameInput+"▌") + "\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) @@ -36,7 +36,7 @@ func (m Model) renderObjectWizardNameStep(width int) string { } else { content.WriteString(hintStyle.Render("Lettres minuscules, chiffres et tirets uniquement (3-63 car).") + "\n\n") } - content.WriteString(hintStyle.Render("Entrée: Suivant • Échap: Annuler")) + content.WriteString(hintStyle.Render("Enter: Next • Esc: Cancel")) return content.String() } @@ -47,7 +47,7 @@ func (m Model) renderObjectWizardTypeStep(width int) string { itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("📦 Type de conteneur :") + "\n\n") + content.WriteString(titleStyle.Render("📦 Container type:") + "\n\n") for i, t := range objectContainerTypes { if i == m.wizard.objectTypeIdx { content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", t)) + "\n") @@ -56,7 +56,7 @@ func (m Model) renderObjectWizardTypeStep(width int) string { } } content.WriteString("\n") - content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Next • Esc: Back")) return content.String() } @@ -67,7 +67,7 @@ func (m Model) renderObjectWizardRegionStep(width int) string { itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("🌍 Localisation (région) :") + "\n\n") + content.WriteString(titleStyle.Render("🌍 Region:") + "\n\n") if len(m.wizard.objectRegions) == 0 { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Aucune région disponible.") + "\n") } else { @@ -96,31 +96,31 @@ func (m Model) renderObjectWizardRegionStep(width int) string { } } content.WriteString("\n") - content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Next • Esc: Back")) return content.String() } func (m Model) renderObjectWizardReplicationStep(width int) string { - return renderObjectToggleStep("🔄 Réplication hors site (Offsite Replication) :", - "Répliquer les objets dans une autre zone géographique automatiquement.", + return renderObjectToggleStep("🔄 Offsite Replication:", + "Automatically replicate objects to another geographic zone.", m.wizard.objectReplication) } func (m Model) renderObjectWizardVersioningStep(width int) string { - return renderObjectToggleStep("📂 Gestion des versions :", - "Conserver plusieurs versions de chaque objet (nécessaire pour Object Lock).", + return renderObjectToggleStep("📂 Versioning:", + "Keep multiple versions of each object (required for Object Lock).", m.wizard.objectVersioning) } func (m Model) renderObjectWizardObjectLockStep(width int) string { - return renderObjectToggleStep("🔒 Object Lock (WORM) :", - "Empêcher la suppression ou modification des objets pendant une période définie.", + return renderObjectToggleStep("🔒 Object Lock (WORM):", + "Prevent deletion or modification of objects for a defined period.", m.wizard.objectLock) } func (m Model) renderObjectWizardEncryptionStep(width int) string { - return renderObjectToggleStep("🔐 Chiffrement côté serveur (AES-256) :", - "Chiffrer automatiquement tous les objets stockés dans ce conteneur.", + return renderObjectToggleStep("🔐 Server-side Encryption (AES-256):", + "Automatically encrypt all objects stored in this container.", m.wizard.objectEncryption) } @@ -140,15 +140,15 @@ func renderObjectToggleStep(title, description string, enabled bool) string { var onBtn, offBtn string if enabled { - onBtn = selectedBorder.Render(onStyle.Render("✓ Activé")) - offBtn = normalBorder.Render(offStyle.Render(" Désactivé")) + onBtn = selectedBorder.Render(onStyle.Render("✓ Enabled")) + offBtn = normalBorder.Render(offStyle.Render(" Disabled")) } else { - onBtn = normalBorder.Render(offStyle.Render(" Activé")) - offBtn = selectedBorder.Render(onStyle.Render("✗ Désactivé")) + onBtn = normalBorder.Render(offStyle.Render(" Enabled")) + offBtn = selectedBorder.Render(onStyle.Render("✗ Disabled")) } content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, onBtn, " ", offBtn)) content.WriteString("\n\n") - content.WriteString(hintStyle.Render("←→ ou y/n: Basculer • Entrée: Suivant • Échap: Retour")) + content.WriteString(hintStyle.Render("←→ or y/n: Toggle • Enter: Next • Esc: Back")) return content.String() } @@ -159,7 +159,7 @@ func (m Model) renderObjectWizardUserStep(width int) string { itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("👤 Utilisateur propriétaire :") + "\n\n") + content.WriteString(titleStyle.Render("👤 Owner user:") + "\n\n") // First option: no user (project-level) if m.wizard.objectUserIdx == 0 { @@ -190,7 +190,7 @@ func (m Model) renderObjectWizardUserStep(width int) string { } content.WriteString("\n") - content.WriteString(hintStyle.Render("↑↓: Sélectionner • Entrée: Suivant • Échap: Retour")) + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Next • Esc: Back")) return content.String() } @@ -200,31 +200,31 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).Bold(true) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("✅ Résumé du conteneur :") + "\n\n") - content.WriteString(labelStyle.Render(" Nom : ") + valueStyle.Render(m.wizard.objectName) + "\n") + content.WriteString(titleStyle.Render("✅ Container summary:") + "\n\n") + content.WriteString(labelStyle.Render(" Name: ") + valueStyle.Render(m.wizard.objectName) + "\n") typeName := "Standard" if m.wizard.objectTypeIdx == 1 { typeName = "High Performance" } - content.WriteString(labelStyle.Render(" Type : ") + valueStyle.Render(typeName) + "\n") + content.WriteString(labelStyle.Render(" Type: ") + valueStyle.Render(typeName) + "\n") region := m.wizard.selectedRegion if region == "" && len(m.wizard.objectRegions) > 0 { region = m.wizard.objectRegions[0] } - content.WriteString(labelStyle.Render(" Région : ") + valueStyle.Render(region) + "\n") - content.WriteString(labelStyle.Render(" Réplication : ") + valueStyle.Render(boolToFrench(m.wizard.objectReplication)) + "\n") - content.WriteString(labelStyle.Render(" Versioning : ") + valueStyle.Render(boolToFrench(m.wizard.objectVersioning)) + "\n") - content.WriteString(labelStyle.Render(" Object Lock : ") + valueStyle.Render(boolToFrench(m.wizard.objectLock)) + "\n") - content.WriteString(labelStyle.Render(" Chiffrement : ") + valueStyle.Render(boolToFrench(m.wizard.objectEncryption)) + "\n") + content.WriteString(labelStyle.Render(" Region: ") + valueStyle.Render(region) + "\n") + content.WriteString(labelStyle.Render(" Replication: ") + valueStyle.Render(boolToEnglish(m.wizard.objectReplication)) + "\n") + content.WriteString(labelStyle.Render(" Versioning: ") + valueStyle.Render(boolToEnglish(m.wizard.objectVersioning)) + "\n") + content.WriteString(labelStyle.Render(" Object Lock: ") + valueStyle.Render(boolToEnglish(m.wizard.objectLock)) + "\n") + content.WriteString(labelStyle.Render(" Encryption: ") + valueStyle.Render(boolToEnglish(m.wizard.objectEncryption)) + "\n") if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] username, _ := user["username"].(string) - content.WriteString(labelStyle.Render(" Utilisateur : ") + valueStyle.Render(username) + "\n") + content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render(username) + "\n") } else { - content.WriteString(labelStyle.Render(" Utilisateur : ") + valueStyle.Render("(aucun)") + "\n") + content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render("(none)") + "\n") } content.WriteString("\n") @@ -241,29 +241,29 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { var createBtn, cancelBtn string if m.wizard.objectConfirmBtnIdx == 0 { - createBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#00FF7F")).Render(createStyle.Render(" ▶ [Créer]")) - cancelBtn = lipgloss.NewStyle().Padding(1).Render(cancelStyle.Render(" [Annuler]")) + createBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#00FF7F")).Render(createStyle.Render(" ▶ [Create]")) + cancelBtn = lipgloss.NewStyle().Padding(1).Render(cancelStyle.Render(" [Cancel]")) } else { - createBtn = lipgloss.NewStyle().Padding(1).Render(createStyle.Render(" [Créer]")) - cancelBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#FF6B6B")).Render(cancelStyle.Render(" ▶ [Annuler]")) + createBtn = lipgloss.NewStyle().Padding(1).Render(createStyle.Render(" [Create]")) + cancelBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#FF6B6B")).Render(cancelStyle.Render(" ▶ [Cancel]")) } content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, createBtn, " ", cancelBtn)) content.WriteString("\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) - content.WriteString(errStyle.Render("⚠ Erreur: "+m.wizard.errorMsg) + "\n\n") - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Basculer • Entrée: Réessayer • N: Changer le nom • Échap: Retour")) + content.WriteString(errStyle.Render("⚠ Error: "+m.wizard.errorMsg) + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Toggle • Enter: Retry • N: Change name • Esc: Back")) } else { - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Basculer • Entrée: Confirmer • Échap: Retour")) + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("←→: Toggle • Enter: Confirm • Esc: Back")) } return content.String() } -func boolToFrench(v bool) string { +func boolToEnglish(v bool) string { if v { - return "Activé" + return "Enabled" } - return "Désactivé" + return "Disabled" } // ─── Object Storage wizard key handlers ────────────────────────────────────── @@ -424,7 +424,7 @@ func (m Model) handleObjectWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { m.wizard.errorMsg = "" // Create m.wizard.isLoading = true - m.wizard.loadingMessage = fmt.Sprintf("Création du conteneur '%s'...", m.wizard.objectName) + m.wizard.loadingMessage = fmt.Sprintf("Creating container '%s'...", m.wizard.objectName) return m, m.createObjectContainer() case "esc": m.wizard.step = ObjectWizardStepEncryption diff --git a/internal/services/browser/views/file_storage/detail.go b/internal/services/browser/views/file_storage/detail.go index 66cee624..b3be808b 100644 --- a/internal/services/browser/views/file_storage/detail.go +++ b/internal/services/browser/views/file_storage/detail.go @@ -68,20 +68,20 @@ func (v *DetailView) Render(width, height int) string { var infoContent strings.Builder infoContent.WriteString(views.RenderKeyValue("ID", id) + "\n") - infoContent.WriteString(views.RenderKeyValue("Nom", name) + "\n") - infoContent.WriteString(views.RenderKeyValue("Statut", views.RenderStatus(status)) + "\n") - infoContent.WriteString(views.RenderKeyValue("Région", region) + "\n") + infoContent.WriteString(views.RenderKeyValue("Name", name) + "\n") + infoContent.WriteString(views.RenderKeyValue("Status", views.RenderStatus(status)) + "\n") + infoContent.WriteString(views.RenderKeyValue("Region", region) + "\n") infoContent.WriteString(views.RenderKeyValue("Type", shareType) + "\n") - infoContent.WriteString(views.RenderKeyValue("Capacité", size+" GB") + "\n") + infoContent.WriteString(views.RenderKeyValue("Capacity", size+" GB") + "\n") if createdAt != "" { - infoContent.WriteString(views.RenderKeyValue("Créé le", createdAt) + "\n") + infoContent.WriteString(views.RenderKeyValue("Created at", createdAt) + "\n") } - content.WriteString(views.RenderBox("Informations du partage", infoContent.String(), width-4)) + content.WriteString(views.RenderBox("Share information", infoContent.String(), width-4)) content.WriteString("\n\n") actionsContent := v.renderActions() - content.WriteString(views.RenderBox("Actions (←/→ pour naviguer, Entrée pour exécuter)", actionsContent, width-4)) + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4)) return content.String() } @@ -95,7 +95,7 @@ func (v *DetailView) renderActions() string { Width(40) return views.StyleStatusWarning.Render("Nouveau nom :") + "\n" + inputStyle.Render(v.renameInput+"▌") + "\n\n" + - views.StyleFooter.Render("Entrée: Confirmer • Échap: Annuler") + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") } if v.extendMode { @@ -106,10 +106,10 @@ func (v *DetailView) renderActions() string { Padding(0, 1). Width(20) return views.StyleStatusWarning.Render( - fmt.Sprintf("Nouvelle taille en GB (actuelle: %s GB, doit être supérieure) :", currentSize), - ) + "\n" + - inputStyle.Render(v.extendInput+"▌") + "\n\n" + - views.StyleFooter.Render("Entrée: Confirmer • Échap: Annuler") + fmt.Sprintf("New size in GB (current: %s GB, must be larger):", currentSize), + ) + "\n" + + inputStyle.Render(v.extendInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") } var parts []string @@ -129,7 +129,7 @@ func (v *DetailView) renderActions() string { if v.confirmMode { result += "\n\n" + views.StyleStatusWarning.Render( - fmt.Sprintf("⚠️ Appuyez sur Entrée pour confirmer %s, Échap pour annuler", + fmt.Sprintf("⚠️ Press Enter to confirm %s, Esc to cancel", fileShareActionLabels[v.selectedAction])) } @@ -263,12 +263,12 @@ func (v *DetailView) Title() string { // HelpText returns the footer help text. func (v *DetailView) HelpText() string { if v.renameMode || v.extendMode { - return "Tapez la valeur • Entrée: Confirmer • Échap: Annuler" + return "Type value • Enter: Confirm • Esc: Cancel" } if v.confirmMode { - return "Entrée: Confirmer l'action • Échap: Annuler" + return "Enter: Confirm action • Esc: Cancel" } - return "←→: Sélectionner • Entrée: Exécuter • Échap: Retour à la liste • q: Quitter" + return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" } // ExecuteFileShareActionMsg is dispatched when the user confirms an action. diff --git a/internal/services/browser/views/object_storage/detail.go b/internal/services/browser/views/object_storage/detail.go index 817f52b6..a8577ce6 100644 --- a/internal/services/browser/views/object_storage/detail.go +++ b/internal/services/browser/views/object_storage/detail.go @@ -64,11 +64,11 @@ func (v *DetailView) Render(width, height int) string { } // Encryption - encryptionStatus := "Désactivé" + encryptionStatus := "Disabled" encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) if enc, ok := v.container["encryption"].(map[string]interface{}); ok { if alg, _ := enc["sseAlgorithm"].(string); alg != "" { - encryptionStatus = "Actif (" + alg + ")" + encryptionStatus = "Active (" + alg + ")" encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) } } @@ -107,20 +107,20 @@ func (v *DetailView) Render(width, height int) string { } var infoContent strings.Builder - infoContent.WriteString(views.RenderKeyValue("Nom", name) + "\n") - infoContent.WriteString(views.RenderKeyValue("Région", region) + "\n") - infoContent.WriteString(views.RenderKeyValue("Créé le", createdAt) + "\n") - infoContent.WriteString(views.RenderKeyValue("Objets", objectsCount) + "\n") - infoContent.WriteString(views.RenderKeyValue("Taille totale", sizeStr) + "\n") + infoContent.WriteString(views.RenderKeyValue("Name", name) + "\n") + infoContent.WriteString(views.RenderKeyValue("Region", region) + "\n") + infoContent.WriteString(views.RenderKeyValue("Created at", createdAt) + "\n") + infoContent.WriteString(views.RenderKeyValue("Objects", objectsCount) + "\n") + infoContent.WriteString(views.RenderKeyValue("Total size", sizeStr) + "\n") infoContent.WriteString(views.RenderKeyValue("Versioning", versioningStatus) + "\n") - infoContent.WriteString(views.StyleLabel.Render("Chiffrement:") + " " + encryptionStyle.Render(encryptionStatus) + "\n") + infoContent.WriteString(views.StyleLabel.Render("Encryption:") + " " + encryptionStyle.Render(encryptionStatus) + "\n") infoContent.WriteString(views.RenderKeyValue("Object Lock", objectLockStatus) + "\n") - content.WriteString(views.RenderBox("Informations du conteneur", infoContent.String(), width-4)) + content.WriteString(views.RenderBox("Container information", infoContent.String(), width-4)) content.WriteString("\n\n") actionsContent := v.renderActions() - content.WriteString(views.RenderBox("Actions (←/→ pour naviguer, Entrée pour exécuter)", actionsContent, width-4)) + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4)) return content.String() } @@ -143,7 +143,7 @@ func (v *DetailView) renderActions() string { if v.confirmMode { result += "\n\n" + views.StyleStatusWarning.Render( - "⚠️ Appuyez sur Entrée pour confirmer la suppression, Échap pour annuler") + "⚠️ Press Enter to confirm deletion, Esc to cancel") } return result @@ -202,9 +202,9 @@ func (v *DetailView) Title() string { // HelpText returns the footer help text. func (v *DetailView) HelpText() string { if v.confirmMode { - return "Entrée: Confirmer la suppression • Échap: Annuler" + return "Enter: Confirm deletion • Esc: Cancel" } - return "←→: Sélectionner • Entrée: Exécuter • Échap: Retour à la liste • q: Quitter" + return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" } // ExecuteContainerActionMsg is dispatched when the user confirms an action. From cb0e9a71d089af7f1ae11e478f5070b98b351b67 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 12:56:40 +0000 Subject: [PATCH 13/55] feat(browser): added when you press enter you can change principal nav or subnav Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 81f45e70..e862bd8f 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -4771,6 +4771,21 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "enter": + // Toggle between main nav and sub-nav when on Storage item + if m.mode != DetailView && m.mode != ProjectSelectView && + m.mode != NodePoolsView && m.mode != NodePoolDetailView { + navItems := getNavItems() + if m.navIdx < len(navItems) && navItems[m.navIdx].Product == ProductStorage { + if m.inStorageSubNav { + // Go back to main nav + m.inStorageSubNav = false + return m, nil + } + // Drop into sub-nav + m.inStorageSubNav = true + return m.loadStorageSubProduct() + } + } // Handle enter based on current mode if m.mode == NodePoolDetailView { // Execute selected action on node pool From e75f051bce7e71448b3166d1d2678a29ef3f12ce Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 13:20:45 +0000 Subject: [PATCH 14/55] feat(browser): fixed when you want details Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 15 +++++++++------ .../services/browser/views/block_storage/table.go | 4 ++-- .../services/browser/views/instances/table.go | 4 ++-- .../services/browser/views/kubernetes/table.go | 4 ++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index e862bd8f..fdebf670 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -4438,13 +4438,13 @@ func (m Model) renderFooter() string { help = "↑↓: Navigate • Enter: Select Project • d: Set Default • q: Quit" case TableView: if m.filterInput != "" { - help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" + help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • v: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" } else if m.inStorageSubNav { - help = "←→: Sub-menu • ↑: Back to main nav • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" + help = "←→: Sub-menu • ↑: Back to main nav • /: Filter • v: Details • c: Create • d: Debug • p: Change Project • q: Quit" } else if m.currentProduct == ProductStorageBlock { - help = "←→: Switch Product • ↓: Enter Sub-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • ↓: Enter Sub-menu • ↑↓: Navigate • /: Filter • v: Details • c: Create • d: Debug • p: Change Project • q: Quit" } else { - help = "←→: Switch Product • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • ↑↓: Navigate • /: Filter • v: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: if m.inStorageSubNav { @@ -4857,8 +4857,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedNodePool = nodePools[m.nodePoolsSelectedIdx] m.mode = NodePoolDetailView } - } else if m.mode == TableView { - // In table view, show details + } + return m, nil + case "v": + // In table view, show details + if m.mode == TableView { selectedRow := m.table.Cursor() if selectedRow >= 0 && selectedRow < len(m.currentData) { m.detailData = m.currentData[selectedRow] diff --git a/internal/services/browser/views/block_storage/table.go b/internal/services/browser/views/block_storage/table.go index c892a304..d0177d5d 100644 --- a/internal/services/browser/views/block_storage/table.go +++ b/internal/services/browser/views/block_storage/table.go @@ -126,7 +126,7 @@ func (v *TableView) HandleKey(msg tea.KeyMsg) tea.Cmd { case "/": v.filterMode = true return nil - case "enter": + case "v": idx := v.table.Cursor() if idx >= 0 && idx < len(v.filteredData) { return func() tea.Msg { @@ -171,7 +171,7 @@ func (v *TableView) HelpText() string { if v.filterMode { return "Type to filter • Enter: Confirm • Esc: Cancel" } - return "↑↓: Navigate • /: Filter • Enter: Details • d: Debug • p: Projects • q: Quit" + return "↑↓: Navigate • /: Filter • v: Details • d: Debug • p: Projects • q: Quit" } func (v *TableView) GetSelectedVolume() map[string]interface{} { diff --git a/internal/services/browser/views/instances/table.go b/internal/services/browser/views/instances/table.go index 53314b75..b546ad5a 100644 --- a/internal/services/browser/views/instances/table.go +++ b/internal/services/browser/views/instances/table.go @@ -159,7 +159,7 @@ func (v *TableView) HandleKey(msg tea.KeyMsg) tea.Cmd { case "/": v.filterMode = true return nil - case "enter": + case "v": // Return selected instance for detail view idx := v.table.Cursor() if idx >= 0 && idx < len(v.filteredData) { @@ -205,7 +205,7 @@ func (v *TableView) HelpText() string { if v.filterMode { return "Type to filter • Enter: Confirm • Esc: Cancel" } - return "↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Projects • q: Quit" + return "↑↓: Navigate • /: Filter • v: Details • c: Create • d: Debug • p: Projects • q: Quit" } // GetSelectedInstance returns the currently selected instance. diff --git a/internal/services/browser/views/kubernetes/table.go b/internal/services/browser/views/kubernetes/table.go index b4b04e9a..1757286f 100644 --- a/internal/services/browser/views/kubernetes/table.go +++ b/internal/services/browser/views/kubernetes/table.go @@ -127,7 +127,7 @@ func (v *TableView) HandleKey(msg tea.KeyMsg) tea.Cmd { case "/": v.filterMode = true return nil - case "enter": + case "v": idx := v.table.Cursor() if idx >= 0 && idx < len(v.filteredData) { return func() tea.Msg { @@ -172,7 +172,7 @@ func (v *TableView) HelpText() string { if v.filterMode { return "Type to filter • Enter: Confirm • Esc: Cancel" } - return "↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Projects • q: Quit" + return "↑↓: Navigate • /: Filter • v: Details • c: Create • d: Debug • p: Projects • q: Quit" } // GetSelectedCluster returns the currently selected cluster. From 2e7e7721ba15b986dfbb975c9c4e8c048549d554 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 28 Apr 2026 14:50:06 +0000 Subject: [PATCH 15/55] feat(browser): added user part in object storage Signed-off-by: olivier dubo --- internal/services/browser/api.go | 64 +++++++++++++++++++++-- internal/services/browser/manager.go | 63 +++++++++++++++++++++++ internal/services/browser/object_api.go | 68 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 28e11700..3848ea8c 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -736,9 +736,60 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { } } + // Fetch cloud users with their S3 credentials + var s3Users []map[string]interface{} + var cloudUsers []map[string]interface{} + userEndpoint := fmt.Sprintf("/v1/cloud/project/%s/user", m.cloudProject) + if err := httpLib.Client.Get(userEndpoint, &cloudUsers); err == nil { + for _, user := range cloudUsers { + userEntry := make(map[string]interface{}) + userEntry["_username"] = user["username"] + userEntry["_userDescription"] = user["description"] + userEntry["_userId"] = user["id"] + + // Robust user ID extraction: handle float64, json.Number, int + var userId int64 + switch id := user["id"].(type) { + case float64: + userId = int64(id) + case json.Number: + userId, _ = id.Int64() + case int: + userId = int64(id) + case int64: + userId = id + } + + if userId == 0 { + userEntry["access"] = "" + userEntry["internalName"] = getString(user, "username") + s3Users = append(s3Users, userEntry) + continue + } + + // Fetch S3 credentials for this user + var s3Creds []map[string]interface{} + s3Endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials", m.cloudProject, userId) + if err := httpLib.Client.Get(s3Endpoint, &s3Creds); err == nil && len(s3Creds) > 0 { + for _, cred := range s3Creds { + cred["_username"] = user["username"] + cred["_userDescription"] = user["description"] + cred["_userId"] = user["id"] + s3Users = append(s3Users, cred) + } + } else { + // User exists but has no S3 credentials yet + userEntry["access"] = "" + userEntry["internalName"] = getString(user, "username") + s3Users = append(s3Users, userEntry) + } + } + } + return dataLoadedMsg{ - data: allContainers, - err: nil, + data: allContainers, + s3Users: s3Users, + err: nil, } } @@ -1417,7 +1468,14 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { case ProductStorageFile: m.table = createFileStorageTable(msg.data, m.width, m.height) case ProductStorageObject: - m.table = createObjectStorageTable(msg.data, m.width, m.height) + // Store S3 users for tabs + m.objectStorageUsers = msg.s3Users + // Start with containers tab (index 0) or respect current tab + if m.objectStorageTabIdx == 0 { + m.table = createObjectStorageTable(msg.data, m.width, m.height) + } else { + m.table = createObjectStorageUsersTable(msg.s3Users, m.width, m.height) + } default: m.table = createGenericTable(msg.data, m.width, m.height) } diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index fdebf670..1ae556a4 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -337,6 +337,8 @@ type Model struct { // Filter mode filterMode bool // Whether filter input mode is active filterInput string // Current filter input text + // Object Storage tab rendering + renderObjectStorageTabs bool // Delete confirmation deleteTarget map[string]interface{} // Item to be deleted deleteConfirmInput string // User input for delete confirmation @@ -360,6 +362,9 @@ type Model struct { fileShareDetailView *file_storage.DetailView // Object Storage detail view objectDetailView *object_storage.DetailView + // Object Storage tabs (0=Containers, 1=Users) + objectStorageTabIdx int + objectStorageUsers []map[string]interface{} } // Navigation items for the top bar @@ -463,6 +468,7 @@ type dataLoadedMsg struct { data []map[string]interface{} err error forProduct ProductType // The product that requested this data + s3Users []map[string]interface{} // S3 users (for Object Storage) } // setDefaultProjectMsg is returned after setting the default project @@ -1359,6 +1365,10 @@ func (m Model) renderContentBox(width int) string { contentStr = m.renderTable() case TableView: contentStr = m.renderTable() + // Add tabs for Object Storage + if m.currentProduct == ProductStorageObject { + contentStr = m.renderObjectStorageWithTabs(contentStr, width-6) + } case DetailView: contentStr = m.renderDetailView(width - 6) case NodePoolsView: @@ -3964,6 +3974,43 @@ func (m Model) renderTable() string { return content.String() } +// renderObjectStorageWithTabs renders the Object Storage view with tabs +func (m Model) renderObjectStorageWithTabs(tableContent string, width int) string { + var content strings.Builder + + // Render tabs + tabActiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 2) + + tabInactiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#333333")). + Foreground(lipgloss.Color("#888888")). + Padding(0, 2) + + tab1 := "My Containers" + tab2 := "Users" + + var tab1Rendered, tab2Rendered string + if m.objectStorageTabIdx == 0 { + tab1Rendered = tabActiveStyle.Render(tab1) + tab2Rendered = tabInactiveStyle.Render(tab2) + } else { + tab1Rendered = tabInactiveStyle.Render(tab1) + tab2Rendered = tabActiveStyle.Render(tab2) + } + + tabs := lipgloss.JoinHorizontal(lipgloss.Top, tab1Rendered, " ", tab2Rendered) + content.WriteString(tabs + "\n\n") + + // Add the table content + content.WriteString(tableContent) + + return content.String() +} + func (m Model) renderDeleteConfirmView() string { var content strings.Builder var instanceName string @@ -4695,6 +4742,22 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "t": + // Toggle between Object Storage tabs (Containers / Users) + if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct == ProductStorageObject { + m.objectStorageTabIdx = (m.objectStorageTabIdx + 1) % 2 + // Rebuild table with appropriate data + if m.objectStorageTabIdx == 0 { + // Show containers + m.table = createObjectStorageTable(m.currentData, m.width, m.height) + } else { + // Show users + m.table = createObjectStorageUsersTable(m.objectStorageUsers, m.width, m.height) + } + return m, nil + } + return m, nil + case "p": // Go back to project selection if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index ad242084..7ac0be8b 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -312,3 +312,71 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) t.SetStyles(s) return t } + +// createObjectStorageUsersTable creates a table to display S3 users/credentials. +func createObjectStorageUsersTable(users []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Description", Width: 40}, + {Title: "Access Key", Width: 32}, + {Title: "User S3", Width: 20}, + } + + var rows []table.Row + for _, s3Cred := range users { + // Name: internalName from credentials, fallback to username + name := getString(s3Cred, "internalName") + if name == "" { + name = getString(s3Cred, "_username") + } + + // Description from user info + description := getString(s3Cred, "_userDescription") + if description == "" { + description = "-" + } + + // Access key + accessKey := getString(s3Cred, "access") + if accessKey == "" { + accessKey = "No credentials" + } + + // S3 User: enabled status + s3User := "Enabled" + if accessKey == "No credentials" { + s3User = "Disabled" + } + + rows = append(rows, table.Row{name, description, accessKey, s3User}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} From 5e4645ae77e5b406548a1fe0a97a562e9b11ca58 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 29 Apr 2026 07:42:01 +0000 Subject: [PATCH 16/55] feat(browser): fixed navigation in object storage Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 1ae556a4..97c9d35b 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -4965,22 +4965,17 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "up", "down", "j", "k": key := msg.String() navItems := getNavItems() - // ↓ on main nav over Stockage → enter sub-nav + isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive if (key == "down" || key == "j") && !m.inStorageSubNav && m.mode != DetailView && - m.mode != ProjectSelectView && navItems[m.navIdx].Product == ProductStorage { + m.mode != ProjectSelectView && !isStorageSubProduct && navItems[m.navIdx].Product == ProductStorage { m.inStorageSubNav = true return m.loadStorageSubProduct() } - // ↑ when sub-nav is focused → exit to main nav if (key == "up" || key == "k") && m.inStorageSubNav && m.mode != DetailView { - m.inStorageSubNav = false - return m, nil - } - isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive - // ↑ when at top of table on a storage sub-product → focus sub-nav - if (key == "up" || key == "k") && isStorageSubProduct && !m.inStorageSubNav && m.mode == TableView && m.table.Cursor() == 0 { - m.inStorageSubNav = true - return m, nil + if m.mode != TableView || m.table.Cursor() == 0 { + m.inStorageSubNav = false + return m, nil + } } // ↑ on EmptyView/ComingSoonView for storage → focus sub-nav if (key == "up" || key == "k") && isStorageSubProduct && !m.inStorageSubNav && (m.mode == EmptyView || m.mode == ComingSoonView) { From ae40347456b4f24533fcc52c5dc423c526ea7a08 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 29 Apr 2026 12:22:53 +0000 Subject: [PATCH 17/55] feat(browser): added create user in object storage Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 82 ++++++++- internal/services/browser/object_api.go | 142 +++++++++++++++ internal/services/browser/object_wizard.go | 199 +++++++++++++++++++-- 3 files changed, 407 insertions(+), 16 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 97c9d35b..52106f8e 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -53,6 +53,7 @@ const ( NodePoolDeleteConfirmView // Node pool delete confirmation KubeKubeconfigPickerView // Directory picker for saving kubeconfig ComingSoonView // Coming soon placeholder for unimplemented products + S3CredentialsView // S3 user credentials display after creation ) // ASCII OVHcloud logo for loading screen @@ -122,6 +123,12 @@ const ( ObjectWizardStepConfirm ) +const ( + // S3 User wizard steps + S3UserWizardStepDescription WizardStep = iota + 600 + S3UserWizardStepConfirm +) + // ProductType represents a product category type ProductType int @@ -309,6 +316,10 @@ type WizardData struct { objectLock bool // Object Lock enabled objectEncryption bool // Encryption enabled (AES256) objectConfirmBtnIdx int // 0=Create, 1=Cancel + // S3 User wizard fields + s3UserDescInput string // Description input buffer + s3UserDesc string // Confirmed description + s3UserConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -365,6 +376,11 @@ type Model struct { // Object Storage tabs (0=Containers, 1=Users) objectStorageTabIdx int objectStorageUsers []map[string]interface{} + // S3 user creation result (for credentials display) + s3CreatedUser map[string]interface{} + s3CreatedCredentials map[string]interface{} + s3CredentialsSavedPath string + s3CredentialsSaveError string } // Navigation items for the top bar @@ -685,6 +701,19 @@ type objectContainerActionDoneMsg struct { action int err error } + +type s3UserCreatedMsg struct { + user map[string]interface{} + credentials map[string]interface{} + err error +} + +type s3CredentialsSavedMsg struct { + filePath string + profileName string + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1081,6 +1110,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case objectContainerActionDoneMsg: return m.handleObjectContainerActionDone(msg) + case s3UserCreatedMsg: + return m.handleS3UserCreated(msg) + + case s3CredentialsSavedMsg: + return m.handleS3CredentialsSaved(msg) + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -1302,7 +1337,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 500 { + if m.wizard.step >= 600 { + // S3 User wizard + titleText = " 👤 Create S3 User " + } else if m.wizard.step >= 500 { // Object Storage wizard titleText = " 🪣 Create Object Storage Container " } else if m.wizard.step >= 400 { @@ -1336,6 +1374,15 @@ func (m Model) renderContentBox(width int) string { return contentBoxStyle.Width(width - 4).Render(fullContent) } + // Handle S3 credentials view + if m.mode == S3CredentialsView { + titleText = " 🔑 S3 User Credentials " + title := productTitleStyle.Render(titleText) + contentStr := m.renderS3CredentialsView(width - 6) + fullContent := title + "\n\n" + contentStr + return contentBoxStyle.Width(width - 4).Render(fullContent) + } + // Handle project selection view specially if m.mode == ProjectSelectView || m.currentProduct == ProductProjects { titleText = " 📦 Select a Project " @@ -2220,7 +2267,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 500 { + if m.wizard.step >= 600 { + // S3 User wizard + steps = append(steps, "Description", "Confirm") + stepMapping = append(stepMapping, S3UserWizardStepDescription, S3UserWizardStepConfirm) + } else if m.wizard.step >= 500 { // Object Storage wizard steps = append(steps, "Nom", "Type", "Région", "Réplication", "Versions", "Lock", "Utilisateur", "Chiffrement", "Confirmer") stepMapping = append(stepMapping, ObjectWizardStepName, ObjectWizardStepType, ObjectWizardStepRegion, ObjectWizardStepReplication, ObjectWizardStepVersioning, ObjectWizardStepObjectLock, ObjectWizardStepUser, ObjectWizardStepEncryption, ObjectWizardStepConfirm) @@ -2388,6 +2439,11 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderObjectWizardEncryptionStep(width)) case ObjectWizardStepConfirm: content.WriteString(m.renderObjectWizardConfirmStep(width)) + // S3 User wizard steps + case S3UserWizardStepDescription: + content.WriteString(m.renderS3UserWizardDescStep(width)) + case S3UserWizardStepConfirm: + content.WriteString(m.renderS3UserWizardConfirmStep(width)) } return content.String() } @@ -4550,11 +4606,17 @@ func (m Model) renderFooter() string { help = "↑↓: Navigate • Enter: Select/Expand • ←: Back • Esc: Cancel" } else if m.wizard.step == FileWizardStepConfirm { help = "←→: Select • Enter: Confirm • Esc: Cancel" + } else if m.wizard.step == S3UserWizardStepDescription { + help = "Type description • Enter: Continue • Esc: Cancel" + } else if m.wizard.step == S3UserWizardStepConfirm { + help = "←→: Select • Enter: Confirm • Esc: Back" } else { help = "↑↓: Navigate • d: Debug • Enter: Select • ←: Back • Esc: Cancel" } case DeleteConfirmView: help = "Type instance name to confirm • Enter: Delete • Esc: Cancel" + case S3CredentialsView: + help = "s: Save to ~/.aws/credentials • Enter/Esc: Continue • q: Quit" case DebugView: help = "↑↓: Scroll • c: Clear logs • d/Esc: Close • q: Quit" case KubeKubeconfigPickerView: @@ -4590,6 +4652,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleWizardKeyPress(msg) } + // Handle S3 credentials display view + if m.mode == S3CredentialsView { + return m.handleS3CredentialsViewKeys(msg) + } + // Handle delete confirmation mode if m.mode == DeleteConfirmView { return m.handleDeleteConfirmKeyPress(msg) @@ -4820,6 +4887,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "c": // Create resource - available in TableView, EmptyView, and NodePoolsView if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct != ProductProjects { + // If viewing S3 users tab, launch user creation wizard + if m.currentProduct == ProductStorageObject && m.objectStorageTabIdx == 1 { + m.mode = WizardView + m.wizard = WizardData{step: S3UserWizardStepDescription} + return m, nil + } return m, m.launchCreationWizard() } // Create node pool from NodePoolsView @@ -5499,6 +5572,11 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case ObjectWizardStepConfirm: return m.handleObjectWizardConfirmKeys(key) + // S3 User wizard steps + case S3UserWizardStepDescription: + return m.handleS3UserWizardDescKeys(msg) + case S3UserWizardStepConfirm: + return m.handleS3UserWizardConfirmKeys(key) } return m, nil } diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index 7ac0be8b..dcaa9729 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -7,9 +7,13 @@ package browser import ( + "encoding/json" "fmt" "net/url" + "os" + "path/filepath" "sort" + "strings" "time" "github.com/charmbracelet/bubbles/table" @@ -163,6 +167,119 @@ func (m Model) deleteObjectContainer(containerName, region string) tea.Cmd { } } +// createS3User creates a new cloud user with objectstore access, then creates S3 credentials for it. +func (m Model) createS3User() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return s3UserCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + + body := map[string]interface{}{ + "description": m.wizard.s3UserDesc, + "role": "objectstore_operator", + } + var user map[string]interface{} + userEndpoint := fmt.Sprintf("/v1/cloud/project/%s/user", m.cloudProject) + if err := httpLib.Client.Post(userEndpoint, body, &user); err != nil { + return s3UserCreatedMsg{err: fmt.Errorf("failed to create user: %w", err)} + } + var userId int64 + switch v := user["id"].(type) { + case float64: + userId = int64(v) + case int64: + userId = v + case int: + userId = int64(v) + case json.Number: + userId, _ = v.Int64() + default: + idStr := fmt.Sprintf("%v", v) + if _, err := fmt.Sscanf(idStr, "%d", &userId); err != nil { + return s3UserCreatedMsg{user: user, err: fmt.Errorf("could not parse user id %q: %w", idStr, err)} + } + } + + // Poll until user status is "ok" (max ~30s) + userGetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d", m.cloudProject, userId) + for i := 0; i < 30; i++ { + var u map[string]interface{} + if err := httpLib.Client.Get(userGetEndpoint, &u); err == nil { + if status, _ := u["status"].(string); status == "ok" { + user = u + break + } + } + time.Sleep(1 * time.Second) + } + + // Create S3 credentials for the user + var credentials map[string]interface{} + credsEndpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials", m.cloudProject, userId) + if err := httpLib.Client.Post(credsEndpoint, nil, &credentials); err != nil { + return s3UserCreatedMsg{user: user, err: fmt.Errorf("user created but S3 credentials failed: %w", err)} + } + + return s3UserCreatedMsg{user: user, credentials: credentials} + } +} + +// saveAWSCredentials appends the S3 credentials to ~/.aws/credentials under a new profile. +func saveAWSCredentials(accessKey, secretKey, username string) tea.Cmd { + return func() tea.Msg { + homeDir, err := os.UserHomeDir() + if err != nil { + return s3CredentialsSavedMsg{err: fmt.Errorf("could not find home directory: %w", err)} + } + + awsDir := filepath.Join(homeDir, ".aws") + if err := os.MkdirAll(awsDir, 0700); err != nil { + return s3CredentialsSavedMsg{err: fmt.Errorf("could not create ~/.aws directory: %w", err)} + } + + credPath := filepath.Join(awsDir, "credentials") + + profileName := "ovhcloud" + if username != "" { + profileName = "ovhcloud-" + username + } + + existingContent := "" + if data, err := os.ReadFile(credPath); err == nil { + existingContent = string(data) + } + + if strings.Contains(existingContent, "["+profileName+"]") { + for i := 2; i < 100; i++ { + candidate := fmt.Sprintf("%s-%d", profileName, i) + if !strings.Contains(existingContent, "["+candidate+"]") { + profileName = candidate + break + } + } + } + + newEntry := fmt.Sprintf("\n[%s]\naws_access_key_id = %s\naws_secret_access_key = %s\n", + profileName, accessKey, secretKey) + + flags := os.O_WRONLY | os.O_APPEND + if existingContent == "" { + flags = os.O_WRONLY | os.O_CREATE + } + f, err := os.OpenFile(credPath, flags, 0600) + if err != nil { + return s3CredentialsSavedMsg{err: fmt.Errorf("could not open credentials file: %w", err)} + } + defer f.Close() + + if _, err := f.WriteString(newEntry); err != nil { + return s3CredentialsSavedMsg{err: fmt.Errorf("could not write credentials: %w", err)} + } + + return s3CredentialsSavedMsg{filePath: credPath, profileName: profileName} + } +} + // ─── Object Storage message handlers ───────────────────────────────────────── func (m Model) handleObjectStorageInitDataLoaded(msg objectStorageInitDataLoadedMsg) (tea.Model, tea.Cmd) { @@ -222,6 +339,31 @@ func (m Model) handleObjectContainerActionDone(msg objectContainerActionDoneMsg) ) } +func (m Model) handleS3UserCreated(msg s3UserCreatedMsg) (tea.Model, tea.Cmd) { + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.s3CreatedUser = msg.user + m.s3CreatedCredentials = msg.credentials + m.s3CredentialsSavedPath = "" + m.s3CredentialsSaveError = "" + m.wizard = WizardData{} + m.mode = S3CredentialsView + return m, nil +} + +func (m Model) handleS3CredentialsSaved(msg s3CredentialsSavedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.s3CredentialsSaveError = msg.err.Error() + } else { + m.s3CredentialsSavedPath = fmt.Sprintf("%s (profile: [%s])", msg.filePath, msg.profileName) + } + return m, nil +} + // createObjectStorageTable builds the table for S3 containers. func createObjectStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index 935a9527..3f930fc6 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -229,25 +229,22 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { content.WriteString("\n") - createStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#00AA55")). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 2) - cancelStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#555555")). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 2) + baseBtn := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 2).Bold(true) + inactiveBtn := baseBtn. + Foreground(lipgloss.Color("#888888")). + BorderForeground(lipgloss.Color("#444444")) var createBtn, cancelBtn string if m.wizard.objectConfirmBtnIdx == 0 { - createBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#00FF7F")).Render(createStyle.Render(" ▶ [Create]")) - cancelBtn = lipgloss.NewStyle().Padding(1).Render(cancelStyle.Render(" [Cancel]")) + createBtn = baseBtn.Foreground(lipgloss.Color("#00FF7F")).BorderForeground(lipgloss.Color("#00FF7F")).Render("✓ Create") + cancelBtn = inactiveBtn.Render("✗ Cancel") } else { - createBtn = lipgloss.NewStyle().Padding(1).Render(createStyle.Render(" [Create]")) - cancelBtn = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#FF6B6B")).Render(cancelStyle.Render(" ▶ [Cancel]")) + createBtn = inactiveBtn.Render("✓ Create") + cancelBtn = baseBtn.Foreground(lipgloss.Color("#FF6B6B")).BorderForeground(lipgloss.Color("#FF6B6B")).Render("✗ Cancel") } - content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, createBtn, " ", cancelBtn)) + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, createBtn, " ", cancelBtn)) content.WriteString("\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) @@ -431,3 +428,177 @@ func (m Model) handleObjectWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { } return m, nil } + +// ─── S3 User wizard render functions ───────────────────────────────────────── + +func (m Model) renderS3UserWizardDescStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7B68EE")). + Padding(0, 1). + Width(40) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) + + content.WriteString(titleStyle.Render("👤 Create S3 User") + "\n\n") + content.WriteString(dimStyle.Render("Enter a description for this user (e.g. \"my-app-user\"):") + "\n\n") + content.WriteString(inputStyle.Render(m.wizard.s3UserDescInput+"▌") + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(errStyle.Render("⚠ "+m.wizard.errorMsg) + "\n\n") + } + + return content.String() +} + +func (m Model) renderS3UserWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + infoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) + + content.WriteString(titleStyle.Render("👤 Confirm S3 User Creation") + "\n\n") + content.WriteString(labelStyle.Render(" Description:") + valueStyle.Render(m.wizard.s3UserDesc) + "\n") + content.WriteString(labelStyle.Render(" Role:") + valueStyle.Render("objectstore_operator") + "\n\n") + content.WriteString(infoStyle.Render(" ℹ An S3 access key + secret key will be generated.\n The secret key will only be shown once.") + "\n\n") + + // Buttons + baseBtn := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 2).Bold(true) + inactiveBtn := baseBtn. + Foreground(lipgloss.Color("#888888")). + BorderForeground(lipgloss.Color("#444444")) + + var createBtn, cancelBtn string + if m.wizard.s3UserConfirmBtnIdx == 0 { + createBtn = baseBtn.Foreground(lipgloss.Color("#00FF7F")).BorderForeground(lipgloss.Color("#00FF7F")).Render("✓ Create") + cancelBtn = inactiveBtn.Render("✗ Cancel") + } else { + createBtn = inactiveBtn.Render("✓ Create") + cancelBtn = baseBtn.Foreground(lipgloss.Color("#FF6B6B")).BorderForeground(lipgloss.Color("#FF6B6B")).Render("✗ Cancel") + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, createBtn, " ", cancelBtn) + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(errStyle.Render("⚠ Error: "+m.wizard.errorMsg) + "\n\n") + } + + return content.String() +} + +// renderS3CredentialsView renders the post-creation credentials display. +func (m Model) renderS3CredentialsView(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) + warningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("✅ S3 User created successfully!") + "\n\n") + content.WriteString(warningStyle.Render("⚠ Save these credentials now — the secret key will never be shown again.") + "\n\n") + + username := getStringValue(m.s3CreatedUser, "username", "") + accessKey := "" + secretKey := "" + if m.s3CreatedCredentials != nil { + accessKey = getStringValue(m.s3CreatedCredentials, "access", "") + secretKey = getStringValue(m.s3CreatedCredentials, "secret", "") + } + + content.WriteString(labelStyle.Render(" Username:") + valueStyle.Render(username) + "\n") + content.WriteString(labelStyle.Render(" Access Key:") + valueStyle.Render(accessKey) + "\n") + content.WriteString(labelStyle.Render(" Secret Key:") + valueStyle.Render(secretKey) + "\n\n") + + if m.s3CredentialsSavedPath != "" { + savedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + content.WriteString(savedStyle.Render("✅ Credentials saved to: "+m.s3CredentialsSavedPath) + "\n\n") + } else if m.s3CredentialsSaveError != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + content.WriteString(errStyle.Render("❌ Save error: "+m.s3CredentialsSaveError) + "\n\n") + } else { + content.WriteString(dimStyle.Render(" Press [s] to save to ~/.aws/credentials") + "\n\n") + } + + content.WriteString(dimStyle.Render(" Press [Enter] or [Esc] to return to the users list") + "\n") + + return content.String() +} + +// ─── S3 User wizard key handlers ───────────────────────────────────────────── + +func (m Model) handleS3UserWizardDescKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + desc := strings.TrimSpace(m.wizard.s3UserDescInput) + if desc == "" { + m.wizard.errorMsg = "Description cannot be empty." + return m, nil + } + m.wizard.errorMsg = "" + m.wizard.s3UserDesc = desc + m.wizard.step = S3UserWizardStepConfirm + case "esc": + m.mode = TableView + m.wizard = WizardData{} + case "backspace": + if len(m.wizard.s3UserDescInput) > 0 { + m.wizard.s3UserDescInput = m.wizard.s3UserDescInput[:len(m.wizard.s3UserDescInput)-1] + } + default: + if len(msg.Runes) > 0 { + m.wizard.s3UserDescInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handleS3UserWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.s3UserConfirmBtnIdx = 0 + case "right", "l": + m.wizard.s3UserConfirmBtnIdx = 1 + case "enter": + if m.wizard.s3UserConfirmBtnIdx == 1 { + m.mode = TableView + m.wizard = WizardData{} + return m, nil + } + m.wizard.errorMsg = "" + m.wizard.isLoading = true + m.wizard.loadingMessage = fmt.Sprintf("Creating user '%s'...", m.wizard.s3UserDesc) + return m, m.createS3User() + case "esc": + m.wizard.step = S3UserWizardStepDescription + } + return m, nil +} + +func (m Model) handleS3CredentialsViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "s": + if m.s3CreatedCredentials != nil && m.s3CredentialsSavedPath == "" && m.s3CredentialsSaveError == "" { + accessKey := getStringValue(m.s3CreatedCredentials, "access", "") + secretKey := getStringValue(m.s3CreatedCredentials, "secret", "") + username := getStringValue(m.s3CreatedUser, "username", "") + return m, saveAWSCredentials(accessKey, secretKey, username) + } + case "enter", "esc": + m.mode = LoadingView + m.s3CreatedUser = nil + m.s3CreatedCredentials = nil + m.s3CredentialsSavedPath = "" + m.s3CredentialsSaveError = "" + return m, m.fetchDataForPath("/storage/object") + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil +} + From 9e529fecc2a34f8f7f116862b365900e79b32e1b Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 29 Apr 2026 13:58:46 +0000 Subject: [PATCH 18/55] feat(browser): fixed object storage Signed-off-by: olivier dubo --- internal/services/browser/api.go | 34 +++- internal/services/browser/manager.go | 28 ++- internal/services/browser/object_api.go | 153 +++++++++++---- internal/services/browser/object_wizard.go | 212 ++++++++++++++++++--- 4 files changed, 352 insertions(+), 75 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 3848ea8c..09fa529c 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -90,9 +90,14 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { } case "/storage/object": return func() tea.Msg { - msg := m.fetchS3StorageData() - msg.forProduct = product - return msg + s3Msg := m.fetchS3StorageData() + swiftMsg := m.fetchSwiftStorageData() + merged := append(s3Msg.data, swiftMsg.data...) + return dataLoadedMsg{ + data: merged, + s3Users: s3Msg.s3Users, + forProduct: product, + } } case "/networks/private": return func() tea.Msg { @@ -714,7 +719,13 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { continue } - // Fetch containers for this region - API may return array of strings or objects + deployMode := "1-AZ" + if regionType, _ := region["type"].(string); regionType == "region-3-az" { + deployMode = "3-AZ" + } else if regionType == "localzone" { + deployMode = "Local Zone" + } + var rawResponse []interface{} storageEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage", m.cloudProject, regionName) if err := httpLib.Client.Get(storageEndpoint, &rawResponse); err == nil { @@ -725,11 +736,14 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { detailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage/%s", m.cloudProject, regionName, containerName) if err := httpLib.Client.Get(detailEndpoint, &container); err == nil { container["_offer"] = s3Offer + container["_deployMode"] = deployMode + container["_type"] = "S3" allContainers = append(allContainers, container) } } else if containerObj, ok := item.(map[string]interface{}); ok { - // It's already a full object containerObj["_offer"] = s3Offer + containerObj["_deployMode"] = deployMode + containerObj["_type"] = "S3" allContainers = append(allContainers, containerObj) } } @@ -801,9 +815,9 @@ func (m Model) fetchSwiftStorageData() dataLoadedMsg { } } - // Try to fetch as array of interfaces first (could be strings or objects) + // includeType=true makes the API return the containerType field (private/public/static) var rawResponse []interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/storage", m.cloudProject) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/storage?includeType=true", m.cloudProject) err := httpLib.Client.Get(endpoint, &rawResponse) if err != nil { return dataLoadedMsg{ @@ -812,16 +826,15 @@ func (m Model) fetchSwiftStorageData() dataLoadedMsg { } } - // Check if response contains strings (IDs) or objects var containers []map[string]interface{} if len(rawResponse) > 0 { if _, ok := rawResponse[0].(string); ok { - // Response contains string IDs, fetch details for each for _, item := range rawResponse { if containerID, ok := item.(string); ok { var container map[string]interface{} - detailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/storage/%s", m.cloudProject, containerID) + detailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/storage/%s?includeType=true", m.cloudProject, containerID) if err := httpLib.Client.Get(detailEndpoint, &container); err == nil { + container["_type"] = "Swift" containers = append(containers, container) } } @@ -830,6 +843,7 @@ func (m Model) fetchSwiftStorageData() dataLoadedMsg { // Response contains full objects for _, item := range rawResponse { if obj, ok := item.(map[string]interface{}); ok { + obj["_type"] = "Swift" containers = append(containers, obj) } } diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 52106f8e..93372dec 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -121,6 +121,8 @@ const ( ObjectWizardStepUser ObjectWizardStepEncryption ObjectWizardStepConfirm + ObjectWizardStepSwiftType + ObjectWizardStepSwiftRegion ) const ( @@ -316,6 +318,9 @@ type WizardData struct { objectLock bool // Object Lock enabled objectEncryption bool // Encryption enabled (AES256) objectConfirmBtnIdx int // 0=Create, 1=Cancel + objectSwiftTypeIdx int // 0=Static, 1=Private, 2=Public + objectSwiftRegions []string // Available regions for Swift + objectSwiftRegion string // Selected Swift region // S3 User wizard fields s3UserDescInput string // Description input buffer s3UserDesc string // Confirmed description @@ -714,6 +719,10 @@ type s3CredentialsSavedMsg struct { err error } +type swiftRegionsLoadedMsg struct { + regions []string +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1116,6 +1125,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case s3CredentialsSavedMsg: return m.handleS3CredentialsSaved(msg) + case swiftRegionsLoadedMsg: + m.wizard.objectSwiftRegions = msg.regions + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + return m, nil + case tea.SuspendMsg: // TUI has been suspended return m, nil @@ -2439,6 +2454,10 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderObjectWizardEncryptionStep(width)) case ObjectWizardStepConfirm: content.WriteString(m.renderObjectWizardConfirmStep(width)) + case ObjectWizardStepSwiftType: + content.WriteString(m.renderObjectWizardSwiftTypeStep(width)) + case ObjectWizardStepSwiftRegion: + content.WriteString(m.renderObjectWizardSwiftRegionStep(width)) // S3 User wizard steps case S3UserWizardStepDescription: content.WriteString(m.renderS3UserWizardDescStep(width)) @@ -5400,13 +5419,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -5572,7 +5591,10 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case ObjectWizardStepConfirm: return m.handleObjectWizardConfirmKeys(key) - // S3 User wizard steps + case ObjectWizardStepSwiftType: + return m.handleObjectWizardSwiftTypeKeys(key) + case ObjectWizardStepSwiftRegion: + return m.handleObjectWizardSwiftRegionKeys(key) case S3UserWizardStepDescription: return m.handleS3UserWizardDescKeys(msg) case S3UserWizardStepConfirm: diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index dcaa9729..94651a85 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -94,13 +94,74 @@ func (m Model) fetchObjectStorageInitData() tea.Cmd { } } -// createObjectContainer creates a new S3 container via the OVH API. +// fetchSwiftRegions loads available regions for Swift containers. +func (m Model) fetchSwiftRegions() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return objectStorageInitDataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + + var regionNames []string + if err := httpLib.Client.Get(fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject), ®ionNames); err != nil { + return swiftRegionsLoadedMsg{regions: nil} + } + type probeResult struct { + region string + supported bool + } + ch := make(chan probeResult, len(regionNames)) + for _, name := range regionNames { + go func(r string) { + var region map[string]interface{} + ep := fmt.Sprintf("/v1/cloud/project/%s/region/%s", m.cloudProject, url.PathEscape(r)) + if err := httpLib.Client.Get(ep, ®ion); err != nil { + ch <- probeResult{region: r, supported: false} + return + } + services, _ := region["services"].([]interface{}) + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if n, _ := sm["name"].(string); n == "storage" || n == "storage-object" { + ch <- probeResult{region: r, supported: true} + return + } + } + } + ch <- probeResult{region: r, supported: false} + }(name) + } + + var supportedRegions []string + for range regionNames { + r := <-ch + if r.supported { + supportedRegions = append(supportedRegions, r.region) + } + } + sort.Strings(supportedRegions) + return swiftRegionsLoadedMsg{regions: supportedRegions} + } +} + func (m Model) createObjectContainer() tea.Cmd { return func() tea.Msg { if m.cloudProject == "" { return objectContainerCreatedMsg{err: fmt.Errorf("no cloud project selected")} } + if m.wizard.objectTypeIdx == 1 { + body := map[string]interface{}{ + "containerName": m.wizard.objectName, + "region": m.wizard.objectSwiftRegion, + } + body["archive"] = (m.wizard.objectSwiftTypeIdx == 1) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/storage", m.cloudProject) + var container map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &container); err != nil { + return objectContainerCreatedMsg{err: fmt.Errorf("failed to create Swift container: %w", err)} + } + return objectContainerCreatedMsg{container: container} + } body := map[string]interface{}{ "name": m.wizard.objectName, } @@ -371,8 +432,8 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) {Title: "Location", Width: 12}, {Title: "Deployment mode", Width: 22}, {Title: "Offer", Width: 16}, - {Title: "Objects", Width: 11}, - {Title: "Used space", Width: 14}, + {Title: "Number of objects", Width: 11}, + {Title: "Space Used", Width: 14}, {Title: "Type", Width: 10}, } @@ -381,49 +442,69 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) name := getString(c, "name") region := getString(c, "region") - // Mode de déploiement: derived from virtualHost presence - virtualHost := getString(c, "virtualHost") - deployMode := "Multi-AZ" - if virtualHost == "" { - deployMode = "Single-AZ" - } - - // Offre: injected by fetchS3StorageData from the region service name - offer := getString(c, "_offer") - if offer == "" { - offer = "Standard" + deployMode := getString(c, "_deployMode") + if deployMode == "" { + deployMode = "-" + } + + category := getString(c, "_type") + + offer := "-" + if category == "Swift" { + offer = "Swift" + } else if category == "S3" { + offer = "S3 Compatible" + } + + containerType := "-" + if category == "Swift" { + swiftType := getString(c, "containerType") + switch swiftType { + case "private": + containerType = "Private" + case "public": + containerType = "Public" + case "static": + containerType = "Static" + default: + if swiftType != "" { + containerType = swiftType + } + } + } else if category == "S3" { + s3Offer := getString(c, "_offer") + if s3Offer != "" { + containerType = s3Offer + } } objectsCount := "-" - if v, ok := c["objectsCount"]; ok { - switch n := v.(type) { - case float64: - objectsCount = fmt.Sprintf("%d", int(n)) + for _, field := range []string{"objectsCount", "storedObjects"} { + if v, ok := c[field]; ok { + if n, ok := v.(float64); ok { + objectsCount = fmt.Sprintf("%d", int(n)) + break + } } } - sizeStr := "-" - if v, ok := c["objectsSize"]; ok { - switch n := v.(type) { - case float64: - if n < 1024 { - sizeStr = fmt.Sprintf("%.0f B", n) - } else if n < 1024*1024 { - sizeStr = fmt.Sprintf("%.1f KB", n/1024) - } else if n < 1024*1024*1024 { - sizeStr = fmt.Sprintf("%.1f MB", n/1024/1024) - } else { - sizeStr = fmt.Sprintf("%.2f GB", n/1024/1024/1024) + for _, field := range []string{"objectsSize", "storedBytes"} { + if v, ok := c[field]; ok { + if n, ok := v.(float64); ok { + if n < 1024 { + sizeStr = fmt.Sprintf("%.0f B", n) + } else if n < 1024*1024 { + sizeStr = fmt.Sprintf("%.1f KB", n/1024) + } else if n < 1024*1024*1024 { + sizeStr = fmt.Sprintf("%.1f MB", n/1024/1024) + } else { + sizeStr = fmt.Sprintf("%.2f GB", n/1024/1024/1024) + } + break } } } - // Type: from the container's own type field if present - containerType := getString(c, "type") - if containerType == "" { - containerType = "-" - } - rows = append(rows, table.Row{name, region, deployMode, offer, objectsCount, sizeStr, containerType}) } diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index 3f930fc6..674a4854 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -16,7 +16,15 @@ import ( // ─── Object Storage wizard render functions ─────────────────────────────────── -var objectContainerTypes = []string{"Standard", "High Performance"} +var objectContainerTypes = []string{ + "S3 (API compatible S3)", + "Swift (Swift API)", +} + +var objectContainerTypeDescriptions = []string{ + "Un large éventail de fonctionnalités compatibles avec S3.\nDisponible en 1-AZ, 3-AZ et Local Zones (Standard ou High Performance selon la région)", + "Solution basique pour le stockage sans besoin particulier en matière de performance.\nStockage objet natif d'OpenStack, avec les API Swift", +} func (m Model) renderObjectWizardNameStep(width int) string { var content strings.Builder @@ -50,9 +58,22 @@ func (m Model) renderObjectWizardTypeStep(width int) string { content.WriteString(titleStyle.Render("📦 Container type:") + "\n\n") for i, t := range objectContainerTypes { if i == m.wizard.objectTypeIdx { - content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", t)) + "\n") + displayText := fmt.Sprintf(" ▶ %s", t) + if i == 0 { + badgeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + displayText += " " + badgeStyle.Render("[Recommandée]") + } + content.WriteString(selectedStyle.Render(displayText) + "\n") + if i < len(objectContainerTypeDescriptions) { + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).MarginLeft(4) + content.WriteString(descStyle.Render(objectContainerTypeDescriptions[i]) + "\n") + } } else { - content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", t)) + "\n") + displayText := fmt.Sprintf(" %s", t) + if i == 0 { + displayText += " [Recommandée]" + } + content.WriteString(itemStyle.Render(displayText) + "\n") } } content.WriteString("\n") @@ -203,32 +224,35 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { content.WriteString(titleStyle.Render("✅ Container summary:") + "\n\n") content.WriteString(labelStyle.Render(" Name: ") + valueStyle.Render(m.wizard.objectName) + "\n") - typeName := "Standard" - if m.wizard.objectTypeIdx == 1 { - typeName = "High Performance" - } + typeName := objectContainerTypes[m.wizard.objectTypeIdx] content.WriteString(labelStyle.Render(" Type: ") + valueStyle.Render(typeName) + "\n") - region := m.wizard.selectedRegion - if region == "" && len(m.wizard.objectRegions) > 0 { - region = m.wizard.objectRegions[0] - } - content.WriteString(labelStyle.Render(" Region: ") + valueStyle.Render(region) + "\n") - content.WriteString(labelStyle.Render(" Replication: ") + valueStyle.Render(boolToEnglish(m.wizard.objectReplication)) + "\n") - content.WriteString(labelStyle.Render(" Versioning: ") + valueStyle.Render(boolToEnglish(m.wizard.objectVersioning)) + "\n") - content.WriteString(labelStyle.Render(" Object Lock: ") + valueStyle.Render(boolToEnglish(m.wizard.objectLock)) + "\n") - content.WriteString(labelStyle.Render(" Encryption: ") + valueStyle.Render(boolToEnglish(m.wizard.objectEncryption)) + "\n") - - if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { - user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] - username, _ := user["username"].(string) - content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render(username) + "\n") + if m.wizard.objectTypeIdx == 1 { + swiftType := swiftContainerTypes[m.wizard.objectSwiftTypeIdx] + content.WriteString(labelStyle.Render(" Swift Type: ") + valueStyle.Render(swiftType) + "\n") + content.WriteString(labelStyle.Render(" Region: ") + valueStyle.Render(m.wizard.objectSwiftRegion) + "\n") + content.WriteString("\n") } else { - content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render("(none)") + "\n") + region := m.wizard.selectedRegion + if region == "" && len(m.wizard.objectRegions) > 0 { + region = m.wizard.objectRegions[0] + } + content.WriteString(labelStyle.Render(" Region: ") + valueStyle.Render(region) + "\n") + content.WriteString(labelStyle.Render(" Replication: ") + valueStyle.Render(boolToEnglish(m.wizard.objectReplication)) + "\n") + content.WriteString(labelStyle.Render(" Versioning: ") + valueStyle.Render(boolToEnglish(m.wizard.objectVersioning)) + "\n") + content.WriteString(labelStyle.Render(" Object Lock: ") + valueStyle.Render(boolToEnglish(m.wizard.objectLock)) + "\n") + content.WriteString(labelStyle.Render(" Encryption: ") + valueStyle.Render(boolToEnglish(m.wizard.objectEncryption)) + "\n") + + if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { + user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] + username, _ := user["username"].(string) + content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render(username) + "\n") + } else { + content.WriteString(labelStyle.Render(" User: ") + valueStyle.Render("(none)") + "\n") + } + content.WriteString("\n") } - content.WriteString("\n") - baseBtn := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). Padding(0, 2).Bold(true) @@ -317,8 +341,13 @@ func (m Model) handleObjectWizardTypeKeys(key string) (tea.Model, tea.Cmd) { m.wizard.objectTypeIdx++ } case "enter": - m.wizard.step = ObjectWizardStepRegion - m.wizard.selectedIndex = 0 + if m.wizard.objectTypeIdx == 1 { + m.wizard.step = ObjectWizardStepSwiftType + m.wizard.selectedIndex = 0 + } else { + m.wizard.step = ObjectWizardStepRegion + m.wizard.selectedIndex = 0 + } case "esc": m.wizard.step = ObjectWizardStepName } @@ -580,6 +609,137 @@ func (m Model) handleS3UserWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Swift wizard render functions ─────────────────────────────────────────── + +var swiftContainerTypes = []string{ + "Static hosting", + "Private", + "Public", +} + +var swiftContainerTypeDescriptions = []string{ + "Hébergement statique - Accès rapide et performant pour vos sites. Liez vos domaines et déposez vos fichiers", + "Privé - Facturation, informations légales, logs. Archivez simplement et selon vos usages", + "Public - Multimédia, binaires, e-commerce. Stockez une infinité de données", +} + +func (m Model) renderObjectWizardSwiftTypeStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("📦 Type de conteneur Swift:") + "\n\n") + for i, t := range swiftContainerTypes { + if i == m.wizard.objectSwiftTypeIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", t)) + "\n") + if i < len(swiftContainerTypeDescriptions) { + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).MarginLeft(4) + content.WriteString(descStyle.Render(swiftContainerTypeDescriptions[i]) + "\n") + } + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", t)) + "\n") + } + } + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Next • Esc: Back")) + return content.String() +} + +func (m Model) renderObjectWizardSwiftRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("🌍 Région:") + "\n\n") + + if len(m.wizard.objectSwiftRegions) == 0 { + content.WriteString(itemStyle.Render(" (aucune région disponible)") + "\n\n") + } else { + maxVisible := 10 + startIdx := 0 + if m.wizard.selectedIndex >= maxVisible { + startIdx = m.wizard.selectedIndex - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.wizard.objectSwiftRegions) { + endIdx = len(m.wizard.objectSwiftRegions) + } + + if startIdx > 0 { + content.WriteString(itemStyle.Render(fmt.Sprintf(" (...%d more above)", startIdx)) + "\n") + } + + for i := startIdx; i < endIdx; i++ { + r := m.wizard.objectSwiftRegions[i] + if i == m.wizard.selectedIndex { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", r)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", r)) + "\n") + } + } + + if endIdx < len(m.wizard.objectSwiftRegions) { + content.WriteString(itemStyle.Render(fmt.Sprintf(" (...%d more below)", len(m.wizard.objectSwiftRegions)-endIdx)) + "\n") + } + content.WriteString("\n") + } + + content.WriteString(hintStyle.Render("↑↓: Navigate • Enter: Next • Esc: Back")) + return content.String() +} + +// ─── Swift wizard key handlers ─────────────────────────────────────────────── + +func (m Model) handleObjectWizardSwiftTypeKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.objectSwiftTypeIdx > 0 { + m.wizard.objectSwiftTypeIdx-- + } + case "down", "j": + if m.wizard.objectSwiftTypeIdx < len(swiftContainerTypes)-1 { + m.wizard.objectSwiftTypeIdx++ + } + case "enter": + m.wizard.step = ObjectWizardStepSwiftRegion + m.wizard.selectedIndex = 0 + // Load Swift regions if not loaded + if len(m.wizard.objectSwiftRegions) == 0 { + m.wizard.isLoading = true + m.wizard.loadingMessage = "Loading regions..." + return m, m.fetchSwiftRegions() + } + case "esc": + m.wizard.step = ObjectWizardStepType + } + return m, nil +} + +func (m Model) handleObjectWizardSwiftRegionKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.selectedIndex > 0 { + m.wizard.selectedIndex-- + } + case "down", "j": + if m.wizard.selectedIndex < len(m.wizard.objectSwiftRegions)-1 { + m.wizard.selectedIndex++ + } + case "enter": + if len(m.wizard.objectSwiftRegions) > 0 { + m.wizard.objectSwiftRegion = m.wizard.objectSwiftRegions[m.wizard.selectedIndex] + m.wizard.step = ObjectWizardStepConfirm + } + case "esc": + m.wizard.step = ObjectWizardStepSwiftType + } + return m, nil +} + func (m Model) handleS3CredentialsViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "s": From 3f65ce5e1aa833025e576eac0bad4484dc7d2e87 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 29 Apr 2026 14:36:13 +0000 Subject: [PATCH 19/55] feat(browser): fixed type in object storage Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 6 ++-- internal/services/browser/object_api.go | 15 ++++---- internal/services/browser/object_wizard.go | 42 +++++++++++++++++++--- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 93372dec..487c34d0 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -5579,10 +5579,10 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleObjectWizardUserKeys(key) case ObjectWizardStepEncryption: switch key { - case "left", "h", "y": - m.wizard.objectEncryption = true - case "right", "l", "n": + case "up", "k": m.wizard.objectEncryption = false + case "down", "j": + m.wizard.objectEncryption = true case "enter": m.wizard.step = ObjectWizardStepConfirm case "esc": diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index 94651a85..d701507b 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -432,7 +432,7 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) {Title: "Location", Width: 12}, {Title: "Deployment mode", Width: 22}, {Title: "Offer", Width: 16}, - {Title: "Number of objects", Width: 11}, + {Title: "Number of objects", Width: 25}, {Title: "Space Used", Width: 14}, {Title: "Type", Width: 10}, } @@ -453,9 +453,15 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) if category == "Swift" { offer = "Swift" } else if category == "S3" { - offer = "S3 Compatible" + s3Offer := getString(c, "_offer") + if s3Offer != "" { + offer = s3Offer + } else { + offer = "S3 Compatible" + } } + // Type: only Swift has a meaningful type (Private/Public/Static); '-' for S3 containerType := "-" if category == "Swift" { swiftType := getString(c, "containerType") @@ -471,11 +477,6 @@ func createObjectStorageTable(data []map[string]interface{}, width, height int) containerType = swiftType } } - } else if category == "S3" { - s3Offer := getString(c, "_offer") - if s3Offer != "" { - containerType = s3Offer - } } objectsCount := "-" diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index 674a4854..4da98466 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -140,9 +140,39 @@ func (m Model) renderObjectWizardObjectLockStep(width int) string { } func (m Model) renderObjectWizardEncryptionStep(width int) string { - return renderObjectToggleStep("🔐 Server-side Encryption (AES-256):", - "Automatically encrypt all objects stored in this container.", - m.wizard.objectEncryption) + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).MarginLeft(2) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("🔐 Chiffrement de vos données") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Les données déversées dans ce conteneur sont chiffrées à la volée par OVHcloud.") + "\n\n") + + options := []struct { + label string + desc string + value bool + }{ + {"Pas de chiffrement", "", false}, + {"Chiffrement côté serveur avec des clés gérées par OVHcloud (SSE-OMK)", "", true}, + } + + for _, opt := range options { + active := (opt.value == m.wizard.objectEncryption) + if active { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", opt.label)) + "\n") + if opt.desc != "" { + content.WriteString(descStyle.Render(opt.desc) + "\n") + } + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", opt.label)) + "\n") + } + } + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Sélectionner • Enter: Suivant • Esc: Retour")) + return content.String() } func renderObjectToggleStep(title, description string, enabled bool) string { @@ -241,7 +271,11 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { content.WriteString(labelStyle.Render(" Replication: ") + valueStyle.Render(boolToEnglish(m.wizard.objectReplication)) + "\n") content.WriteString(labelStyle.Render(" Versioning: ") + valueStyle.Render(boolToEnglish(m.wizard.objectVersioning)) + "\n") content.WriteString(labelStyle.Render(" Object Lock: ") + valueStyle.Render(boolToEnglish(m.wizard.objectLock)) + "\n") - content.WriteString(labelStyle.Render(" Encryption: ") + valueStyle.Render(boolToEnglish(m.wizard.objectEncryption)) + "\n") + encryptionLabel := "Pas de chiffrement" + if m.wizard.objectEncryption { + encryptionLabel = "SSE-OMK (clés OVHcloud)" + } + content.WriteString(labelStyle.Render(" Chiffrement: ") + valueStyle.Render(encryptionLabel) + "\n") if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] From 0c93ece22d90031698db1db9b6664f02bdda6bc3 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 30 Apr 2026 07:49:30 +0000 Subject: [PATCH 20/55] feat(browser): added change user and fixed type and offer Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 102 +++- internal/services/browser/object_api.go | 54 +- .../browser/views/object_storage/detail.go | 467 ++++++++++++++---- internal/services/browser/views/styles.go | 6 + 4 files changed, 525 insertions(+), 104 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 487c34d0..5253b2f9 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -703,8 +703,20 @@ type objectContainerCreatedMsg struct { } type objectContainerActionDoneMsg struct { - action int - err error + action int + err error +} + +type swiftContainerUpdatedMsg struct { + containerName string + newType string + err error +} + +type containerPolicyAddedMsg struct { + containerName string + roleName string + err error } type s3UserCreatedMsg struct { @@ -1070,9 +1082,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { region = r } } - m.notification = fmt.Sprintf("🗑️ Deleting container '%s'...", containerName) - m.notificationExpiry = time.Now().Add(30 * time.Second) - return m, m.deleteObjectContainer(containerName, region) + switch msg.Action { + case object_storage.ContainerActionDelete: + m.notification = fmt.Sprintf("🗑️ Deleting container '%s'...", containerName) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.deleteObjectContainer(containerName, region) + case object_storage.ContainerActionChangeType: + newType := "" + if msg.ExtraData != nil { + newType, _ = msg.ExtraData["containerType"].(string) + } + containerID := getString(msg.Container, "id") + if containerID == "" { + containerID = containerName + } + m.notification = fmt.Sprintf("🔄 Changing type of '%s' to %s...", containerName, newType) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.updateSwiftContainerType(containerID, newType) + case object_storage.ContainerActionAddPolicy: + var userID int64 + roleName := "" + if msg.ExtraData != nil { + roleName, _ = msg.ExtraData["roleName"].(string) + switch v := msg.ExtraData["userId"].(type) { + case float64: + userID = int64(v) + case int64: + userID = v + case int: + userID = int64(v) + case json.Number: + userID, _ = v.Int64() + case string: + fmt.Sscanf(v, "%d", &userID) + } + } + m.notification = fmt.Sprintf("🔄 Adding user access to '%s'...", containerName) + m.notificationExpiry = time.Now().Add(30 * time.Second) + if userID == 0 { + m.notification = fmt.Sprintf("❌ Impossible de résoudre l'ID utilisateur (type: %T, val: %v)", msg.ExtraData["userId"], msg.ExtraData["userId"]) + m.notificationExpiry = time.Now().Add(10 * time.Second) + return m, tea.Tick(10*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + return m, m.addS3ContainerPolicy(containerName, region, userID, roleName) + } case views.GoBackMsg: if m.mode == DetailView && m.currentProduct == ProductStorageBlock { @@ -1119,6 +1172,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case objectContainerActionDoneMsg: return m.handleObjectContainerActionDone(msg) + case swiftContainerUpdatedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Type du conteneur '%s' changé en %s", msg.containerName, msg.newType) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.objectDetailView = nil + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case containerPolicyAddedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur lors de l'ajout de la politique: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Accès '%s' ajouté sur '%s'", msg.roleName, msg.containerName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.objectDetailView = nil + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case s3UserCreatedMsg: return m.handleS3UserCreated(msg) @@ -5036,11 +5121,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.fileShareDetailView = file_storage.NewDetailView(ctx, m.detailData) return m, nil } - - // If viewing an object storage container, init the detail view if m.currentProduct == ProductStorageObject { + if m.objectStorageTabIdx == 1 { + return m, nil + } ctx := &views.Context{Width: m.width, Height: m.height} - m.objectDetailView = object_storage.NewDetailView(ctx, m.detailData) + m.objectDetailView = object_storage.NewDetailView(ctx, m.detailData, m.objectStorageUsers) return m, nil } diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index d701507b..eddbf940 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -203,9 +203,6 @@ func (m Model) createObjectContainer() tea.Cmd { } } - // Container type (storageClass in name? No — it's a separate field per region). - // Type is encoded in the region selection (High Perf vs Standard regions). - region := m.wizard.selectedRegion endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage", m.cloudProject, url.PathEscape(region)) @@ -218,6 +215,42 @@ func (m Model) createObjectContainer() tea.Cmd { } } +func (m Model) updateSwiftContainerType(containerID, newType string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return swiftContainerUpdatedMsg{err: fmt.Errorf("no cloud project selected")} + } + + switch newType { + case "static": + // Enable static website hosting + endpoint := fmt.Sprintf("/v1/cloud/project/%s/storage/%s/static", + m.cloudProject, url.PathEscape(containerID)) + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, nil, &result); err != nil { + return swiftContainerUpdatedMsg{containerName: containerID, err: fmt.Errorf("failed to enable static: %w", err)} + } + case "private", "public": + // First remove static hosting if any + staticEp := fmt.Sprintf("/v1/cloud/project/%s/storage/%s/static", + m.cloudProject, url.PathEscape(containerID)) + _ = httpLib.Client.Delete(staticEp, nil) // ignore error if wasn't static + + // Update container ACL via containerType field + body := map[string]interface{}{"containerType": newType} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/storage/%s", + m.cloudProject, url.PathEscape(containerID)) + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return swiftContainerUpdatedMsg{containerName: containerID, err: fmt.Errorf("failed to update container type: %w", err)} + } + default: + return swiftContainerUpdatedMsg{containerName: containerID, err: fmt.Errorf("type inconnu: %s", newType)} + } + + return swiftContainerUpdatedMsg{containerName: containerID, newType: newType} + } +} + // deleteObjectContainer deletes an S3 container. func (m Model) deleteObjectContainer(containerName, region string) tea.Cmd { return func() tea.Msg { @@ -228,6 +261,21 @@ func (m Model) deleteObjectContainer(containerName, region string) tea.Cmd { } } +func (m Model) addS3ContainerPolicy(containerName, region string, userID int64, roleName string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return containerPolicyAddedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/storage/%s/policy/%d", + m.cloudProject, url.PathEscape(region), url.PathEscape(containerName), userID) + body := map[string]interface{}{"roleName": roleName} + if err := httpLib.Client.Post(endpoint, body, nil); err != nil { + return containerPolicyAddedMsg{containerName: containerName, err: fmt.Errorf("failed to add policy: %w", err)} + } + return containerPolicyAddedMsg{containerName: containerName, roleName: roleName} + } +} + // createS3User creates a new cloud user with objectstore access, then creates S3 credentials for it. func (m Model) createS3User() tea.Cmd { return func() tea.Msg { diff --git a/internal/services/browser/views/object_storage/detail.go b/internal/services/browser/views/object_storage/detail.go index a8577ce6..9acb890e 100644 --- a/internal/services/browser/views/object_storage/detail.go +++ b/internal/services/browser/views/object_storage/detail.go @@ -15,39 +15,92 @@ import ( "github.com/ovh/ovhcloud-cli/internal/services/browser/views" ) -// Action indices for object storage container detail view +// Action identifiers for object storage container detail view. const ( - ContainerActionDelete = iota + ContainerActionDelete = iota + ContainerActionChangeType + ContainerActionAddPolicy ) -var containerActionLabels = []string{"Delete"} +const ( + subMenuNone = 0 + subMenuChangeType = 1 + subMenuAddPolicy = 2 + subMenuPickRole = 3 +) + +var swiftTypeOptions = []string{"Private", "Public", "Static"} +var policyRoles = []string{"readWrite", "readOnly", "admin", "deny"} + +// containerAction pairs an action ID with a button label. +type containerAction struct { + id int + label string +} // DetailView displays object storage container details with actions. type DetailView struct { views.BaseView container map[string]interface{} + users []map[string]interface{} // cloud users selectedAction int confirmMode bool + subMenu int + subMenuIdx int + policyUserIdx int } -// NewDetailView creates a detail view for an S3 container. -func NewDetailView(ctx *views.Context, container map[string]interface{}) *DetailView { +// NewDetailView creates a detail view for a container. +func NewDetailView(ctx *views.Context, container map[string]interface{}, users []map[string]interface{}) *DetailView { return &DetailView{ - BaseView: views.NewBaseView(ctx), - container: container, - selectedAction: 0, - confirmMode: false, + BaseView: views.NewBaseView(ctx), + container: container, + users: users, } } -// Render displays the full detail panel. -func (v *DetailView) Render(width, height int) string { - var content strings.Builder +func (v *DetailView) category() string { + if t, ok := v.container["_type"].(string); ok { + return t + } + return "" +} + +// getActions returns the available actions for this container. +func (v *DetailView) getActions() []containerAction { + acts := []containerAction{{id: ContainerActionDelete, label: "Delete"}} + if v.category() == "Swift" { + acts = append(acts, containerAction{id: ContainerActionChangeType, label: "Change Type"}) + } else if v.category() == "S3" { + acts = append(acts, containerAction{id: ContainerActionAddPolicy, label: "Add User"}) + } + return acts +} + +// ─── Render ─────────────────────────────────────────────────────────────────── +func (v *DetailView) Render(width, height int) string { if v.container == nil { return views.StyleError.Render("No container data available") } + var content strings.Builder + content.WriteString(views.RenderBox("Container information", v.renderInfo(), width-4)) + content.WriteString("\n\n") + switch v.subMenu { + case subMenuChangeType: + content.WriteString(v.renderChangeTypeMenu(width)) + case subMenuAddPolicy: + content.WriteString(v.renderPickUserMenu(width)) + case subMenuPickRole: + content.WriteString(v.renderPickRoleMenu(width)) + default: + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", v.renderActions(), width-4)) + } + return content.String() +} +func (v *DetailView) renderInfo() string { + var info strings.Builder name := getString(v.container, "name") region := getString(v.container, "region") createdAt := getString(v.container, "createdAt") @@ -55,154 +108,349 @@ func (v *DetailView) Render(width, height int) string { createdAt = "-" } - // Versioning - versioningStatus := "-" - if vers, ok := v.container["versioning"].(map[string]interface{}); ok { - if s, ok := vers["status"].(string); ok { - versioningStatus = s - } - } + info.WriteString(views.RenderKeyValue("Name", name) + "\n") + info.WriteString(views.RenderKeyValue("Region", region) + "\n") + info.WriteString(views.RenderKeyValue("Created at", createdAt) + "\n") + info.WriteString(views.RenderKeyValue("API", v.category()) + "\n") - // Encryption - encryptionStatus := "Disabled" - encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - if enc, ok := v.container["encryption"].(map[string]interface{}); ok { - if alg, _ := enc["sseAlgorithm"].(string); alg != "" { - encryptionStatus = "Active (" + alg + ")" - encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + if v.category() == "Swift" { + swiftType := getString(v.container, "containerType") + if swiftType == "" { + swiftType = "-" } - } - - // Object lock - objectLockStatus := "-" - if ol, ok := v.container["objectLock"].(map[string]interface{}); ok { - if s, _ := ol["status"].(string); s != "" { - objectLockStatus = s + info.WriteString(views.RenderKeyValue("Type", swiftType) + "\n") + info.WriteString(views.RenderKeyValue("Objects", getCountStr(v.container, "storedObjects", "objectsCount")) + "\n") + info.WriteString(views.RenderKeyValue("Total size", getSizeStr(v.container, "storedBytes", "objectsSize")) + "\n") + } else { + // Versioning + versioningStatus := "-" + if vers, ok := v.container["versioning"].(map[string]interface{}); ok { + if s, ok := vers["status"].(string); ok { + versioningStatus = s + } } - } - // Objects count and size - objectsCount := "-" - if v, ok := v.container["objectsCount"]; ok { - switch n := v.(type) { - case float64: - objectsCount = fmt.Sprintf("%d", int(n)) + // Encryption + encryptionStatus := "Pas de chiffrement" + encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + if enc, ok := v.container["encryption"].(map[string]interface{}); ok { + if alg, _ := enc["sseAlgorithm"].(string); alg != "" { + encryptionStatus = "SSE-OMK (clés gérées par OVHcloud)" + encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + } } - } - sizeStr := "-" - if sz, ok := v.container["objectsSize"]; ok { - switch n := sz.(type) { - case float64: - if n < 1024 { - sizeStr = fmt.Sprintf("%.0f B", n) - } else if n < 1024*1024 { - sizeStr = fmt.Sprintf("%.1f KB", n/1024) - } else if n < 1024*1024*1024 { - sizeStr = fmt.Sprintf("%.1f MB", n/1024/1024) - } else { - sizeStr = fmt.Sprintf("%.2f GB", n/1024/1024/1024) + // Object lock + objectLockStatus := "-" + if ol, ok := v.container["objectLock"].(map[string]interface{}); ok { + if s, _ := ol["status"].(string); s != "" { + objectLockStatus = s } } - } - var infoContent strings.Builder - infoContent.WriteString(views.RenderKeyValue("Name", name) + "\n") - infoContent.WriteString(views.RenderKeyValue("Region", region) + "\n") - infoContent.WriteString(views.RenderKeyValue("Created at", createdAt) + "\n") - infoContent.WriteString(views.RenderKeyValue("Objects", objectsCount) + "\n") - infoContent.WriteString(views.RenderKeyValue("Total size", sizeStr) + "\n") - infoContent.WriteString(views.RenderKeyValue("Versioning", versioningStatus) + "\n") - infoContent.WriteString(views.StyleLabel.Render("Encryption:") + " " + encryptionStyle.Render(encryptionStatus) + "\n") - infoContent.WriteString(views.RenderKeyValue("Object Lock", objectLockStatus) + "\n") - - content.WriteString(views.RenderBox("Container information", infoContent.String(), width-4)) - content.WriteString("\n\n") - - actionsContent := v.renderActions() - content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4)) + // Owner: resolve ownerId → username if possible + ownerDisplay := "-" + if rawID, ok := v.container["ownerId"]; ok { + ownerIDStr := fmt.Sprintf("%v", rawID) + ownerDisplay = ownerIDStr + for _, u := range v.users { + if fmt.Sprintf("%v", u["_userId"]) == ownerIDStr { + if uname := getString(u, "_username"); uname != "" { + ownerDisplay = uname + } + break + } + } + } - return content.String() + info.WriteString(views.RenderKeyValue("Objects", getCountStr(v.container, "objectsCount", "storedObjects")) + "\n") + info.WriteString(views.RenderKeyValue("Total size", getSizeStr(v.container, "objectsSize", "storedBytes")) + "\n") + info.WriteString(views.RenderKeyValue("Owner", ownerDisplay) + "\n") + info.WriteString(views.RenderKeyValue("Versioning", versioningStatus) + "\n") + info.WriteString(views.StyleLabel.Render("Encryption:") + " " + encryptionStyle.Render(encryptionStatus) + "\n") + info.WriteString(views.RenderKeyValue("Object Lock", objectLockStatus) + "\n") + } + return info.String() } func (v *DetailView) renderActions() string { + actions := v.getActions() var parts []string - for i, label := range containerActionLabels { + for i, act := range actions { var style lipgloss.Style if i == v.selectedAction { - style = views.StyleButtonSelected - } else if label == "Delete" { + if act.label == "Delete" { + style = views.StyleButtonDangerSelected + } else { + style = views.StyleButtonSelected + } + } else if act.label == "Delete" { style = views.StyleButtonDanger } else { style = views.StyleButton } - parts = append(parts, style.Render("["+label+"]")) + parts = append(parts, style.Render("["+act.label+"]")) } - result := strings.Join(parts, " ") - if v.confirmMode { - result += "\n\n" + views.StyleStatusWarning.Render( - "⚠️ Press Enter to confirm deletion, Esc to cancel") + result += "\n\n" + views.StyleStatusWarning.Render("⚠️ Press Enter to confirm deletion, Esc to cancel") } + return result +} +func (v *DetailView) renderChangeTypeMenu(width int) string { + var content strings.Builder + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + current := getString(v.container, "containerType") + if current != "" { + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + content.WriteString(descStyle.Render(fmt.Sprintf("Type actuel : %s", current)) + "\n\n") + } + for i, opt := range swiftTypeOptions { + if i == v.subMenuIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", opt)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", opt)) + "\n") + } + } + content.WriteString("\n") + content.WriteString(hintStyle.Render("↑↓: Sélectionner • Enter: Confirmer • Esc: Annuler")) + return views.RenderBox("Modifier le type du conteneur", content.String(), width-4) +} + +func (v *DetailView) policyUserCandidates() []map[string]interface{} { + seen := map[string]bool{} + var result []map[string]interface{} + for _, u := range v.users { + uid := fmt.Sprintf("%v", u["_userId"]) + if uid == "" || uid == "0" || uid == "" { + continue + } + if seen[uid] { + continue + } + seen[uid] = true + result = append(result, u) + } return result } -// HandleKey processes keyboard input and returns a command. +func (v *DetailView) renderPickUserMenu(width int) string { + var content strings.Builder + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + + candidates := v.policyUserCandidates() + if len(candidates) == 0 { + content.WriteString(dimStyle.Render("Aucun utilisateur cloud disponible. Créez d'abord un utilisateur S3.") + "\n") + content.WriteString("\n" + hintStyle.Render("Esc: Annuler")) + return views.RenderBox("Ajouter un accès utilisateur", content.String(), width-4) + } + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Sélectionnez l'utilisateur à autoriser :") + "\n\n") + for i, u := range candidates { + name := fmt.Sprintf("%v", u["_username"]) + if name == "" || name == "" { + name = fmt.Sprintf("%v", u["internalName"]) + } + if i == v.subMenuIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", name)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", name)) + "\n") + } + } + content.WriteString("\n" + hintStyle.Render("↑↓: Sélectionner • Enter: Suivant • Esc: Annuler")) + return views.RenderBox("Ajouter un accès utilisateur (1/2 : Utilisateur)", content.String(), width-4) +} + +func (v *DetailView) renderPickRoleMenu(width int) string { + var content strings.Builder + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + roleDescriptions := map[string]string{ + "readWrite": "Lecture + écriture (recommandé)", + "readOnly": "Lecture seule", + "admin": "Accès complet (admin)", + "deny": "Refuser tout accès", + } + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Sélectionnez le rôle :") + "\n\n") + for i, role := range policyRoles { + desc := roleDescriptions[role] + if i == v.subMenuIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %-12s %s", role, desc)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %-12s %s", role, desc)) + "\n") + } + } + content.WriteString("\n" + hintStyle.Render("↑↓: Sélectionner • Enter: Confirmer • Esc: Retour")) + return views.RenderBox("Ajouter un accès utilisateur (2/2 : Rôle)", content.String(), width-4) +} + +// ─── Key handling ───────────────────────────────────────────────────────────── + func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { key := msg.String() + switch v.subMenu { + case subMenuChangeType: + return v.handleSubMenuKey(key) + case subMenuAddPolicy: + return v.handlePickUserKey(key) + case subMenuPickRole: + return v.handlePickRoleKey(key) + } + + actions := v.getActions() switch key { case "left": if v.selectedAction > 0 { v.selectedAction-- v.confirmMode = false } - return nil case "right": - if v.selectedAction < len(containerActionLabels)-1 { + if v.selectedAction < len(actions)-1 { v.selectedAction++ v.confirmMode = false } - return nil case "enter": if v.confirmMode { v.confirmMode = false + container := v.container return func() tea.Msg { - return ExecuteContainerActionMsg{ - Container: v.container, - Action: v.selectedAction, - } + return ExecuteContainerActionMsg{Container: container, Action: ContainerActionDelete} } } - switch v.selectedAction { + if v.selectedAction >= len(actions) { + return nil + } + switch actions[v.selectedAction].id { case ContainerActionDelete: v.confirmMode = true + case ContainerActionChangeType: + v.subMenu = subMenuChangeType + v.subMenuIdx = 0 + case ContainerActionAddPolicy: + v.subMenu = subMenuAddPolicy + v.subMenuIdx = 0 } - return nil case "esc": if v.confirmMode { v.confirmMode = false return nil } + return func() tea.Msg { return views.GoBackMsg{} } + } + return nil +} + +func (v *DetailView) handlePickUserKey(key string) tea.Cmd { + candidates := v.policyUserCandidates() + switch key { + case "up": + if v.subMenuIdx > 0 { + v.subMenuIdx-- + } + case "down": + if v.subMenuIdx < len(candidates)-1 { + v.subMenuIdx++ + } + case "enter": + if len(candidates) > 0 { + v.policyUserIdx = v.subMenuIdx + v.subMenu = subMenuPickRole + v.subMenuIdx = 0 + } + case "esc": + v.subMenu = subMenuNone + } + return nil +} + +func (v *DetailView) handlePickRoleKey(key string) tea.Cmd { + switch key { + case "up": + if v.subMenuIdx > 0 { + v.subMenuIdx-- + } + case "down": + if v.subMenuIdx < len(policyRoles)-1 { + v.subMenuIdx++ + } + case "enter": + v.subMenu = subMenuNone + candidates := v.policyUserCandidates() + selectedUser := candidates[v.policyUserIdx] + roleName := policyRoles[v.subMenuIdx] + container := v.container + return func() tea.Msg { + return ExecuteContainerActionMsg{ + Container: container, + Action: ContainerActionAddPolicy, + ExtraData: map[string]interface{}{ + "userId": selectedUser["_userId"], + "roleName": roleName, + }, + } + } + case "esc": + v.subMenu = subMenuAddPolicy + v.subMenuIdx = v.policyUserIdx + } + return nil +} + +func (v *DetailView) handleSubMenuKey(key string) tea.Cmd { + switch key { + case "up": + if v.subMenuIdx > 0 { + v.subMenuIdx-- + } + case "down": + if v.subMenuIdx < len(swiftTypeOptions)-1 { + v.subMenuIdx++ + } + case "enter": + v.subMenu = subMenuNone + newType := strings.ToLower(swiftTypeOptions[v.subMenuIdx]) + container := v.container return func() tea.Msg { - return views.GoBackMsg{} + return ExecuteContainerActionMsg{ + Container: container, + Action: ContainerActionChangeType, + ExtraData: map[string]interface{}{"containerType": newType}, + } } + case "esc": + v.subMenu = subMenuNone } return nil } -// Title returns the header title. + +// ─── Metadata ──────────────────────────────────────────────────────────────── + func (v *DetailView) Title() string { name := getString(v.container, "name") - return fmt.Sprintf(" 🪣 Object Storage > %s ", name) + return fmt.Sprintf(" Object Storage > %s ", name) } -// HelpText returns the footer help text. func (v *DetailView) HelpText() string { + switch v.subMenu { + case subMenuChangeType: + return "↑↓: Sélectionner • Enter: Confirmer • Esc: Annuler" + case subMenuAddPolicy: + return "↑↓: Sélectionner l'utilisateur • Enter: Suivant • Esc: Annuler" + case subMenuPickRole: + return "↑↓: Sélectionner le rôle • Enter: Confirmer • Esc: Retour" + } if v.confirmMode { - return "Enter: Confirm deletion • Esc: Cancel" + return "Enter: Confirmer la suppression • Esc: Annuler" } return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" } @@ -211,11 +459,44 @@ func (v *DetailView) HelpText() string { type ExecuteContainerActionMsg struct { Container map[string]interface{} Action int + ExtraData map[string]interface{} } +// ─── Helpers ────────────────────────────────────────────────────────────────── + func getString(m map[string]interface{}, key string) string { if v, ok := m[key].(string); ok { return v } return "" } + +func getCountStr(m map[string]interface{}, fields ...string) string { + for _, f := range fields { + if v, ok := m[f]; ok { + if n, ok := v.(float64); ok { + return fmt.Sprintf("%d", int(n)) + } + } + } + return "-" +} + +func getSizeStr(m map[string]interface{}, fields ...string) string { + for _, f := range fields { + if v, ok := m[f]; ok { + if n, ok := v.(float64); ok { + if n < 1024 { + return fmt.Sprintf("%.0f B", n) + } else if n < 1024*1024 { + return fmt.Sprintf("%.1f KB", n/1024) + } else if n < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", n/1024/1024) + } + return fmt.Sprintf("%.2f GB", n/1024/1024/1024) + } + } + } + return "-" +} + diff --git a/internal/services/browser/views/styles.go b/internal/services/browser/views/styles.go index fb8162d2..f153ecb4 100644 --- a/internal/services/browser/views/styles.go +++ b/internal/services/browser/views/styles.go @@ -113,6 +113,12 @@ var ( Foreground(ColorDanger). Padding(0, 1) + StyleButtonDangerSelected = lipgloss.NewStyle(). + Background(ColorDanger). + Foreground(ColorWhite). + Bold(true). + Padding(0, 1) + StyleButtonSuccess = lipgloss.NewStyle(). Foreground(ColorSecondary). Padding(0, 1) From d9b68d64c72fb904ae5f224b5b219be5efefe92e Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 30 Apr 2026 09:34:13 +0000 Subject: [PATCH 21/55] feat(browser): added edit user and can change status Signed-off-by: olivier dubo --- internal/services/browser/api.go | 12 +- internal/services/browser/manager.go | 164 +++++++++++- internal/services/browser/object_api.go | 60 +++++ internal/services/browser/object_wizard.go | 7 +- .../views/object_storage/user_detail.go | 235 ++++++++++++++++++ 5 files changed, 468 insertions(+), 10 deletions(-) create mode 100644 internal/services/browser/views/object_storage/user_detail.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 09fa529c..d43038aa 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -781,16 +781,14 @@ func (m Model) fetchS3StorageData() dataLoadedMsg { continue } - // Fetch S3 credentials for this user var s3Creds []map[string]interface{} s3Endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials", m.cloudProject, userId) if err := httpLib.Client.Get(s3Endpoint, &s3Creds); err == nil && len(s3Creds) > 0 { - for _, cred := range s3Creds { - cred["_username"] = user["username"] - cred["_userDescription"] = user["description"] - cred["_userId"] = user["id"] - s3Users = append(s3Users, cred) - } + cred := s3Creds[0] + cred["_username"] = user["username"] + cred["_userDescription"] = user["description"] + cred["_userId"] = user["id"] + s3Users = append(s3Users, cred) } else { // User exists but has no S3 credentials yet userEntry["access"] = "" diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 5253b2f9..2d3edd46 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -378,6 +378,8 @@ type Model struct { fileShareDetailView *file_storage.DetailView // Object Storage detail view objectDetailView *object_storage.DetailView + // Object Storage user detail view + objectUserDetailView *object_storage.UserDetailView // Object Storage tabs (0=Containers, 1=Users) objectStorageTabIdx int objectStorageUsers []map[string]interface{} @@ -386,6 +388,8 @@ type Model struct { s3CreatedCredentials map[string]interface{} s3CredentialsSavedPath string s3CredentialsSaveError string + s3PendingEnableUser map[string]interface{} // user being enabled (for credentials display) + s3CredentialsFromEnable bool // true if S3CredentialsView opened from enable action } // Navigation items for the top bar @@ -719,6 +723,17 @@ type containerPolicyAddedMsg struct { err error } +type s3SecretLoadedMsg struct { + secret string + err error +} + +type s3UserActionDoneMsg struct { + action int + newCredential map[string]interface{} + err error +} + type s3UserCreatedMsg struct { user map[string]interface{} credentials map[string]interface{} @@ -1071,6 +1086,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileShareActionDoneMsg: return m.handleFileShareActionDone(msg) + case object_storage.ExecuteUserActionMsg: + return m.handleExecuteUserAction(msg) + + case s3SecretLoadedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Impossible de récupérer la secret key: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + if m.objectUserDetailView != nil { + m.objectUserDetailView.SetSecret(msg.secret) + } + return m, nil + + case s3UserActionDoneMsg: + return m.handleS3UserActionDone(msg) + case object_storage.ExecuteContainerActionMsg: containerName := "" region := "" @@ -1139,6 +1171,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.mode == DetailView && m.currentProduct == ProductStorageObject { + if m.objectUserDetailView != nil { + m.objectUserDetailView = nil + m.mode = TableView + return m, nil + } m.objectDetailView = nil m.mode = TableView return m, nil @@ -1231,6 +1268,104 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // CreationCommand stores the command to run after browser exits var CreationCommand string +// handleExecuteUserAction dispatches actions from the user detail view. +func (m Model) handleExecuteUserAction(msg object_storage.ExecuteUserActionMsg) (tea.Model, tea.Cmd) { + if msg.User == nil { + return m, nil + } + + // Extract userID + var userID int64 + switch v := msg.User["_userId"].(type) { + case float64: + userID = int64(v) + case int64: + userID = v + case int: + userID = int64(v) + case json.Number: + userID, _ = v.Int64() + case string: + fmt.Sscanf(v, "%d", &userID) + } + if userID == 0 { + m.notification = fmt.Sprintf("❌ Impossible de résoudre l'ID utilisateur (type: %T, val: %v)", msg.User["_userId"], msg.User["_userId"]) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + + switch msg.Action { + case object_storage.UserActionShowSecret: + access := fmt.Sprintf("%v", msg.User["access"]) + m.notification = "🔄 Récupération de la secret key..." + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.getS3Secret(userID, access) + case object_storage.UserActionEnable: + m.s3PendingEnableUser = msg.User + m.notification = "🔄 Activation de l'utilisateur..." + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.enableS3User(userID) + case object_storage.UserActionDisable: + access := fmt.Sprintf("%v", msg.User["access"]) + m.notification = fmt.Sprintf("🔄 Désactivation... (userID=%d, access=%s)", userID, access) + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.disableS3User(userID, access) + case object_storage.UserActionDeleteUser: + m.notification = "🗑️ Suppression de l'utilisateur..." + m.notificationExpiry = time.Now().Add(30 * time.Second) + return m, m.deleteCloudUser(userID) + } + return m, nil +} + +// handleS3UserActionDone handles the result of enable/disable/delete user actions. +func (m Model) handleS3UserActionDone(msg s3UserActionDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + + switch msg.action { + case object_storage.UserActionEnable: + // Show S3CredentialsView like after user creation + username := "" + if m.s3PendingEnableUser != nil { + if u, ok := m.s3PendingEnableUser["_username"].(string); ok && u != "" { + username = u + } + } + m.s3CreatedUser = map[string]interface{}{"username": username} + m.s3CreatedCredentials = msg.newCredential + m.s3CredentialsSavedPath = "" + m.s3CredentialsSaveError = "" + m.s3PendingEnableUser = nil + m.objectUserDetailView = nil + m.s3CredentialsFromEnable = true + m.mode = S3CredentialsView + return m, nil + case object_storage.UserActionDisable: + m.notification = "✅ Utilisateur désactivé" + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.objectUserDetailView = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case object_storage.UserActionDeleteUser: + m.objectUserDetailView = nil + m.mode = LoadingView + m.notification = "✅ Utilisateur supprimé" + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Batch( + m.fetchDataForPath("/storage/object"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + return m, nil +} + // handleSetDefaultProject handles the result of setting a default project func (m Model) handleSetDefaultProject(msg setDefaultProjectMsg) (tea.Model, tea.Cmd) { if msg.err != nil { @@ -1491,7 +1626,11 @@ func (m Model) renderContentBox(width int) string { currentNav := navItems[m.navIdx] // Product title - show item name in detail view - if m.mode == DetailView && m.currentItemName != "" { + if m.mode == DetailView && m.currentProduct == ProductStorageObject && m.objectUserDetailView != nil { + titleText = m.objectUserDetailView.Title() + } else if m.mode == DetailView && m.currentProduct == ProductStorageObject && m.objectDetailView != nil { + titleText = m.objectDetailView.Title() + } else if m.mode == DetailView && m.currentItemName != "" { titleText = fmt.Sprintf(" %s %s > %s ", currentNav.Icon, currentNav.Label, m.currentItemName) } else { titleText = fmt.Sprintf(" %s %s ", currentNav.Icon, currentNav.Label) @@ -4237,6 +4376,9 @@ func (m Model) renderDetailView(width int) string { } return m.renderGenericDetail(width) case ProductStorageObject: + if m.objectUserDetailView != nil { + return m.objectUserDetailView.Render(width, 0) + } if m.objectDetailView != nil { return m.objectDetailView.Render(width, 0) } @@ -4662,7 +4804,11 @@ func (m Model) renderFooter() string { help = "←→: Switch Product • c: Create • d: Debug • p: Change Project • q: Quit" } case DetailView: - if m.actionConfirm { + if m.currentProduct == ProductStorageObject && m.objectUserDetailView != nil { + help = m.objectUserDetailView.HelpText() + } else if m.currentProduct == ProductStorageObject && m.objectDetailView != nil { + help = m.objectDetailView.HelpText() + } else if m.actionConfirm { help = "Enter: Confirm Action • Esc: Cancel" } else { help = "←→: Select Action • Enter: Execute • d: Debug • Esc: Back to List • q: Quit" @@ -4819,6 +4965,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + // Delegate to object storage user detail view + if m.mode == DetailView && m.currentProduct == ProductStorageObject && m.objectUserDetailView != nil { + cmd := m.objectUserDetailView.HandleKey(msg) + return m, cmd + } + switch msg.String() { case "left": // In NodePoolDetailView, navigate actions @@ -5123,6 +5275,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if m.currentProduct == ProductStorageObject { if m.objectStorageTabIdx == 1 { + // Open user detail view — use objectStorageUsers for full data + selectedRow := m.table.Cursor() + if selectedRow < 0 || selectedRow >= len(m.objectStorageUsers) { + return m, nil + } + ctx := &views.Context{Width: m.width, Height: m.height} + m.objectUserDetailView = object_storage.NewUserDetailView(ctx, m.objectStorageUsers[selectedRow]) + m.mode = DetailView return m, nil } ctx := &views.Context{Width: m.width, Height: m.height} diff --git a/internal/services/browser/object_api.go b/internal/services/browser/object_api.go index eddbf940..8314a655 100644 --- a/internal/services/browser/object_api.go +++ b/internal/services/browser/object_api.go @@ -276,6 +276,66 @@ func (m Model) addS3ContainerPolicy(containerName, region string, userID int64, } } +func (m Model) getS3Secret(userID int64, access string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return s3SecretLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials/%s/secret", + m.cloudProject, userID, url.PathEscape(access)) + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, nil, &result); err != nil { + return s3SecretLoadedMsg{err: fmt.Errorf("failed to retrieve secret: %w", err)} + } + secret, _ := result["secret"].(string) + return s3SecretLoadedMsg{secret: secret} + } +} + +func (m Model) enableS3User(userID int64) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return s3UserActionDoneMsg{action: object_storage.UserActionEnable, err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials", m.cloudProject, userID) + var cred map[string]interface{} + if err := httpLib.Client.Post(endpoint, nil, &cred); err != nil { + return s3UserActionDoneMsg{action: object_storage.UserActionEnable, err: fmt.Errorf("failed to create credentials: %w", err)} + } + return s3UserActionDoneMsg{action: object_storage.UserActionEnable, newCredential: cred} + } +} + +func (m Model) disableS3User(userID int64, access string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return s3UserActionDoneMsg{action: object_storage.UserActionDisable, err: fmt.Errorf("no cloud project selected")} + } + if access == "" || access == "" { + return s3UserActionDoneMsg{action: object_storage.UserActionDisable, err: fmt.Errorf("access key vide (userID=%d)", userID)} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d/s3Credentials/%s", + m.cloudProject, userID, url.PathEscape(access)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return s3UserActionDoneMsg{action: object_storage.UserActionDisable, err: fmt.Errorf("failed to delete credentials: %w", err)} + } + return s3UserActionDoneMsg{action: object_storage.UserActionDisable} + } +} + +func (m Model) deleteCloudUser(userID int64) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return s3UserActionDoneMsg{action: object_storage.UserActionDeleteUser, err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/user/%d", m.cloudProject, userID) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return s3UserActionDoneMsg{action: object_storage.UserActionDeleteUser, err: fmt.Errorf("failed to delete user: %w", err)} + } + return s3UserActionDoneMsg{action: object_storage.UserActionDeleteUser} + } +} + // createS3User creates a new cloud user with objectstore access, then creates S3 credentials for it. func (m Model) createS3User() tea.Cmd { return func() tea.Msg { diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index 4da98466..cfb2b86c 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -563,7 +563,11 @@ func (m Model) renderS3CredentialsView(width int) string { valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("✅ S3 User created successfully!") + "\n\n") + if m.s3CredentialsFromEnable { + content.WriteString(titleStyle.Render("✅ Utilisateur activé avec succès !") + "\n\n") + } else { + content.WriteString(titleStyle.Render("✅ S3 User created successfully!") + "\n\n") + } content.WriteString(warningStyle.Render("⚠ Save these credentials now — the secret key will never be shown again.") + "\n\n") username := getStringValue(m.s3CreatedUser, "username", "") @@ -789,6 +793,7 @@ func (m Model) handleS3CredentialsViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) m.s3CreatedCredentials = nil m.s3CredentialsSavedPath = "" m.s3CredentialsSaveError = "" + m.s3CredentialsFromEnable = false return m, m.fetchDataForPath("/storage/object") case "q", "ctrl+c": return m, tea.Quit diff --git a/internal/services/browser/views/object_storage/user_detail.go b/internal/services/browser/views/object_storage/user_detail.go new file mode 100644 index 00000000..e6c16a73 --- /dev/null +++ b/internal/services/browser/views/object_storage/user_detail.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package object_storage + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +// Action identifiers for user detail view. +const ( + UserActionShowSecret = iota + UserActionDisable + UserActionEnable + UserActionDeleteUser +) + +// UserDetailView displays S3 user details with activate/deactivate and secret key actions. +type UserDetailView struct { + views.BaseView + user map[string]interface{} + selectedAction int + confirmMode bool + secretKey string // populated after Show Secret action + showSecret bool +} + +// NewUserDetailView creates a detail view for an S3 user. +func NewUserDetailView(ctx *views.Context, user map[string]interface{}) *UserDetailView { + return &UserDetailView{ + BaseView: views.NewBaseView(ctx), + user: user, + } +} + +func (v *UserDetailView) hasCredentials() bool { + acc := fmt.Sprintf("%v", v.user["access"]) + return acc != "" && acc != "" && acc != "No credentials" +} + +func (v *UserDetailView) getActions() []containerAction { + var acts []containerAction + if v.hasCredentials() { + acts = append(acts, containerAction{id: UserActionShowSecret, label: "Show Secret"}) + acts = append(acts, containerAction{id: UserActionDisable, label: "Disable"}) + } else { + acts = append(acts, containerAction{id: UserActionEnable, label: "Enable"}) + } + acts = append(acts, containerAction{id: UserActionDeleteUser, label: "Delete User"}) + return acts +} + +// ─── Render ─────────────────────────────────────────────────────────────────── + +func (v *UserDetailView) Render(width, height int) string { + if v.user == nil { + return views.StyleError.Render("No user data available") + } + var content strings.Builder + content.WriteString(views.RenderBox("User information", v.renderInfo(), width-4)) + content.WriteString("\n\n") + + if v.showSecret && v.secretKey != "" { + content.WriteString(v.renderSecretBox(width)) + content.WriteString("\n\n") + } + + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", v.renderActions(), width-4)) + return content.String() +} + +func (v *UserDetailView) renderInfo() string { + var info strings.Builder + + name := fmt.Sprintf("%v", v.user["_username"]) + if name == "" || name == "" { + name = getString(v.user, "internalName") + } + description := getString(v.user, "_userDescription") + if description == "" { + description = "-" + } + userID := fmt.Sprintf("%v", v.user["_userId"]) + + statusLabel := "Disabled" + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + if v.hasCredentials() { + statusLabel = "Active" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + } + + info.WriteString(views.RenderKeyValue("Username", name) + "\n") + info.WriteString(views.RenderKeyValue("Description", description) + "\n") + info.WriteString(views.RenderKeyValue("User ID", userID) + "\n") + info.WriteString(views.StyleLabel.Render("Status:") + " " + statusStyle.Render(statusLabel) + "\n") + + if v.hasCredentials() { + access := fmt.Sprintf("%v", v.user["access"]) + info.WriteString(views.RenderKeyValue("Access Key", access) + "\n") + } + + return info.String() +} + +func (v *UserDetailView) renderSecretBox(width int) string { + var content strings.Builder + secretStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(secretStyle.Render(v.secretKey) + "\n\n") + content.WriteString(hintStyle.Render("⚠️ Notez cette clé, elle ne sera plus affichée.")) + return views.RenderBox("Secret Key", content.String(), width-4) +} + +func (v *UserDetailView) renderActions() string { + actions := v.getActions() + var parts []string + for i, act := range actions { + var style lipgloss.Style + if i == v.selectedAction { + switch act.label { + case "Delete User", "Disable": + style = views.StyleButtonDangerSelected + default: + style = views.StyleButtonSelected + } + } else { + switch act.label { + case "Delete User", "Disable": + style = views.StyleButtonDanger + default: + style = views.StyleButton + } + } + parts = append(parts, style.Render("["+act.label+"]")) + } + result := strings.Join(parts, " ") + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render("⚠️ Press Enter to confirm, Esc to cancel") + } + return result +} + +// ─── Key handling ───────────────────────────────────────────────────────────── + +func (v *UserDetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + actions := v.getActions() + + switch key { + case "left": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + case "right": + if v.selectedAction < len(actions)-1 { + v.selectedAction++ + v.confirmMode = false + } + case "enter": + if v.confirmMode { + v.confirmMode = false + user := v.user + action := actions[v.selectedAction].id + return func() tea.Msg { + return ExecuteUserActionMsg{User: user, Action: action} + } + } + if v.selectedAction >= len(actions) { + return nil + } + act := actions[v.selectedAction] + switch act.id { + case UserActionShowSecret, UserActionEnable: + user := v.user + return func() tea.Msg { + return ExecuteUserActionMsg{User: user, Action: act.id} + } + case UserActionDisable, UserActionDeleteUser: + v.confirmMode = true + } + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + if v.showSecret { + v.showSecret = false + v.secretKey = "" + return nil + } + return func() tea.Msg { return views.GoBackMsg{} } + } + return nil +} + +// SetSecret populates the revealed secret key and shows it. +func (v *UserDetailView) SetSecret(secret string) { + v.secretKey = secret + v.showSecret = true +} + +// ─── Metadata ──────────────────────────────────────────────────────────────── + +func (v *UserDetailView) Title() string { + name := fmt.Sprintf("%v", v.user["_username"]) + if name == "" || name == "" { + name = getString(v.user, "internalName") + } + return fmt.Sprintf(" 👤 Object Storage > Users > %s ", name) +} + +func (v *UserDetailView) HelpText() string { + if v.confirmMode { + return "Enter: Confirmer • Esc: Annuler" + } + if v.showSecret { + return "Esc: Fermer la secret key" + } + return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" +} + +// ExecuteUserActionMsg is dispatched when the user confirms an action. +type ExecuteUserActionMsg struct { + User map[string]interface{} + Action int +} From 4b25a3f06768323f268dc21dc373158cb898698b Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 30 Apr 2026 12:32:32 +0000 Subject: [PATCH 22/55] feat(browser): added volume backup and snapshot Signed-off-by: olivier dubo --- internal/services/browser/api.go | 166 ++++++++++++ internal/services/browser/backup_api.go | 77 ++++++ internal/services/browser/backup_wizard.go | 289 +++++++++++++++++++++ internal/services/browser/manager.go | 95 ++++++- 4 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 internal/services/browser/backup_api.go create mode 100644 internal/services/browser/backup_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index d43038aa..dbe701a7 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -117,6 +117,18 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/storage/snapshot": + return func() tea.Msg { + msg := m.fetchVolumeSnapshotsData() + msg.forProduct = product + return msg + } + case "/storage/backup": + return func() tea.Msg { + msg := m.fetchVolumeBackupsData() + msg.forProduct = product + return msg + } default: return nil } @@ -1488,6 +1500,10 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { } else { m.table = createObjectStorageUsersTable(msg.s3Users, m.width, m.height) } + case ProductStorageSnapshot: + m.table = createVolumeSnapshotsTable(msg.data, m.width, m.height) + case ProductStorageBackup: + m.table = createVolumeBackupsTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -4120,3 +4136,153 @@ func (m Model) handleNodePoolDeleted(msg nodePoolDeletedMsg) (tea.Model, tea.Cmd return m, m.fetchKubeNodePools(clusterId) } +// fetchVolumeSnapshotsData fetches the list of volume snapshots. +func (m Model) fetchVolumeSnapshotsData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/snapshot", m.cloudProject) + var raw []interface{} + if err := httpLib.Client.Get(endpoint, &raw); err != nil { + return dataLoadedMsg{err: err} + } + var snapshots []map[string]interface{} + for _, item := range raw { + if obj, ok := item.(map[string]interface{}); ok { + snapshots = append(snapshots, obj) + } + } + return dataLoadedMsg{data: snapshots} +} + +// fetchVolumeBackupsData fetches volume backups across all regions. +func (m Model) fetchVolumeBackupsData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + regEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(regEndpoint, ®ionNames); err != nil { + return dataLoadedMsg{err: err} + } + var backups []map[string]interface{} + for _, region := range regionNames { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeBackup", + m.cloudProject, url.PathEscape(region)) + var raw []interface{} + if err := httpLib.Client.Get(endpoint, &raw); err != nil { + continue // skip regions without backup support + } + for _, item := range raw { + if obj, ok := item.(map[string]interface{}); ok { + backups = append(backups, obj) + } + } + } + return dataLoadedMsg{data: backups} +} + +func createVolumeSnapshotsTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 24}, + {Title: "ID", Width: 36}, + {Title: "Location", Width: 14}, + {Title: "Volume", Width: 36}, + {Title: "Capacity", Width: 10}, + {Title: "Status", Width: 14}, + {Title: "Creation date", Width: 20}, + } + var rows []table.Row + for _, s := range data { + name := getString(s, "name") + id := getString(s, "id") + region := getString(s, "region") + volumeId := getString(s, "volumeId") + size := "-" + switch v := s["size"].(type) { + case float64: + size = fmt.Sprintf("%d GB", int(v)) + case json.Number: + if i, err := v.Int64(); err == nil { + size = fmt.Sprintf("%d GB", i) + } + } + status := getString(s, "status") + created := getString(s, "creationDate") + if len(created) > 19 { + created = created[:19] + } + rows = append(rows, table.Row{name, id, region, volumeId, size, status, created}) + } + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(true) + s.Selected = s.Selected.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Bold(false) + t.SetStyles(s) + return t +} + +func createVolumeBackupsTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Nom", Width: 24}, + {Title: "ID", Width: 36}, + {Title: "Location", Width: 14}, + {Title: "Volume", Width: 36}, + {Title: "Capacity", Width: 10}, + {Title: "Status", Width: 14}, + {Title: "Creation date", Width: 20}, + } + var rows []table.Row + for _, b := range data { + name := getString(b, "name") + id := getString(b, "id") + region := getString(b, "region") + volumeId := getString(b, "volumeId") + size := "-" + switch v := b["size"].(type) { + case float64: + size = fmt.Sprintf("%d GB", int(v)) + case json.Number: + if i, err := v.Int64(); err == nil { + size = fmt.Sprintf("%d GB", i) + } + } + status := getString(b, "status") + created := getString(b, "creationDate") + if len(created) > 19 { + created = created[:19] + } + rows = append(rows, table.Row{name, id, region, volumeId, size, status, created}) + } + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(true) + s.Selected = s.Selected.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Bold(false) + t.SetStyles(s) + return t +} + diff --git a/internal/services/browser/backup_api.go b/internal/services/browser/backup_api.go new file mode 100644 index 00000000..429f1222 --- /dev/null +++ b/internal/services/browser/backup_api.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net/url" + + tea "github.com/charmbracelet/bubbletea" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +type volumeBackupCreatedMsg struct { + name string + backupType string + err error +} + +// createVolumeBackupOrSnapshot dispatches to snapshot or backup creation. +func (m Model) createVolumeBackupOrSnapshot(volumeID, region, name string, isSnapshot bool) tea.Cmd { + if isSnapshot { + return m.createVolumeSnapshot(volumeID, name) + } + return m.createVolumeBackup(volumeID, region, name) +} + +func (m Model) createVolumeSnapshot(volumeID, name string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeBackupCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s/snapshot", + m.cloudProject, url.PathEscape(volumeID)) + body := map[string]interface{}{"name": name} + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return volumeBackupCreatedMsg{name: name, backupType: "snapshot", err: fmt.Errorf("failed to create snapshot: %w", err)} + } + return volumeBackupCreatedMsg{name: name, backupType: "snapshot"} + } +} + +func (m Model) createVolumeBackup(volumeID, region, name string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return volumeBackupCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeBackup", + m.cloudProject, url.PathEscape(region)) + body := map[string]interface{}{ + "name": name, + "volumeId": volumeID, + } + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return volumeBackupCreatedMsg{name: name, backupType: "backup", err: fmt.Errorf("failed to create backup: %w", err)} + } + return volumeBackupCreatedMsg{name: name, backupType: "backup"} + } +} + +// fetchBackupVolumes fetches available block storage volumes to use as backup source. +func (m Model) fetchBackupVolumes() tea.Cmd { + return func() tea.Msg { + msg := m.fetchBlockStorageData() + return backupVolumesLoadedMsg{volumes: msg.data, err: msg.err} + } +} + +type backupVolumesLoadedMsg struct { + volumes []map[string]interface{} + err error +} diff --git a/internal/services/browser/backup_wizard.go b/internal/services/browser/backup_wizard.go new file mode 100644 index 00000000..0014551f --- /dev/null +++ b/internal/services/browser/backup_wizard.go @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var backupTypes = []string{"Snapshot", "Backup"} +var backupTypeDescriptions = []string{ + "Instantané du volume — rapide, lié au volume source", + "Sauvegarde complète indépendante — peut être restaurée même si le volume est supprimé", +} + +// ─── Render functions ───────────────────────────────────────────────────────── + +func (m Model) renderBackupWizard(width int) string { + if m.wizard.isLoading { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).Render("⏳ " + m.wizard.loadingMessage) + } + switch m.wizard.step { + case BackupWizardStepVolume: + return m.renderBackupWizardVolumeStep(width) + case BackupWizardStepType: + return m.renderBackupWizardTypeStep(width) + case BackupWizardStepName: + return m.renderBackupWizardNameStep(width) + case BackupWizardStepConfirm: + return m.renderBackupWizardConfirmStep(width) + } + return "" +} + +func (m Model) renderBackupWizardVolumeStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + content.WriteString(titleStyle.Render("💾 Choisissez un volume à sauvegarder :") + "\n\n") + + if len(m.wizard.backupVolumes) == 0 { + content.WriteString(dimStyle.Render(" Aucun volume disponible.") + "\n") + } else { + maxVisible := 12 + startIdx := 0 + if m.wizard.backupVolumeIdx >= maxVisible { + startIdx = m.wizard.backupVolumeIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.wizard.backupVolumes) { + endIdx = len(m.wizard.backupVolumes) + } + if startIdx > 0 { + content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d au-dessus)", startIdx)) + "\n") + } + for i := startIdx; i < endIdx; i++ { + v := m.wizard.backupVolumes[i] + name := getStringValue(v, "name", "-") + region := getStringValue(v, "region", "-") + size := 0 + if s, ok := v["size"].(float64); ok { + size = int(s) + } + label := fmt.Sprintf("%-30s %s %d GB", name, region, size) + if i == m.wizard.backupVolumeIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", label)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", label)) + "\n") + } + } + if endIdx < len(m.wizard.backupVolumes) { + content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d en-dessous)", len(m.wizard.backupVolumes)-endIdx)) + "\n") + } + } + content.WriteString("\n" + hintStyle.Render("↑↓: Naviguer • Enter: Suivant • Esc: Annuler")) + return content.String() +} + +func (m Model) renderBackupWizardTypeStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")).MarginLeft(4) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + volName := "" + if m.wizard.backupVolumeIdx < len(m.wizard.backupVolumes) { + volName = getStringValue(m.wizard.backupVolumes[m.wizard.backupVolumeIdx], "name", "-") + } + content.WriteString(titleStyle.Render(fmt.Sprintf("Volume : %s", volName)) + "\n\n") + content.WriteString(titleStyle.Render("Choisissez un type de sauvegarde :") + "\n\n") + + for i, t := range backupTypes { + if i == m.wizard.backupTypeIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", t)) + "\n") + content.WriteString(descStyle.Render(backupTypeDescriptions[i]) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", t)) + "\n") + } + } + content.WriteString("\n" + hintStyle.Render("↑↓: Naviguer • Enter: Suivant • Esc: Retour")) + return content.String() +} + +func (m Model) renderBackupWizardNameStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + + backupTypeName := backupTypes[m.wizard.backupTypeIdx] + content.WriteString(titleStyle.Render(fmt.Sprintf("Nommez votre %s :", strings.ToLower(backupTypeName))) + "\n\n") + content.WriteString(" " + inputStyle.Render(m.wizard.backupNameInput+"▌") + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(errStyle.Render(" ❌ "+m.wizard.errorMsg) + "\n\n") + } + content.WriteString(hintStyle.Render("Tapez un nom • Enter: Suivant • Esc: Retour")) + return content.String() +} + +func (m Model) renderBackupWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + + vol := m.wizard.backupVolumes[m.wizard.backupVolumeIdx] + volName := getStringValue(vol, "name", "-") + volRegion := getStringValue(vol, "region", "-") + backupTypeName := backupTypes[m.wizard.backupTypeIdx] + + content.WriteString(titleStyle.Render("Confirmer la création :") + "\n\n") + content.WriteString(labelStyle.Render(" Volume :") + valueStyle.Render(volName) + "\n") + content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(volRegion) + "\n") + content.WriteString(labelStyle.Render(" Type :") + valueStyle.Render(backupTypeName) + "\n") + content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.backupName) + "\n\n") + + baseBtn := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 2).Bold(true) + inactiveBtn := baseBtn. + Foreground(lipgloss.Color("#888888")). + BorderForeground(lipgloss.Color("#444444")) + + var createBtn, cancelBtn string + if m.wizard.backupConfirmBtnIdx == 0 { + createBtn = baseBtn.Foreground(lipgloss.Color("#00FF7F")).BorderForeground(lipgloss.Color("#00FF7F")).Render("✓ Créer") + cancelBtn = inactiveBtn.Render("✗ Annuler") + } else { + createBtn = inactiveBtn.Render("✓ Créer") + cancelBtn = baseBtn.Foreground(lipgloss.Color("#FF6B6B")).BorderForeground(lipgloss.Color("#FF6B6B")).Render("✗ Annuler") + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, createBtn, " ", cancelBtn)) + content.WriteString("\n\n") + content.WriteString(hintStyle.Render("←→: Sélectionner • Enter: Confirmer • Esc: Retour")) + return content.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handleBackupWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch m.wizard.step { + case BackupWizardStepVolume: + return m.handleBackupWizardVolumeKeys(key) + case BackupWizardStepType: + return m.handleBackupWizardTypeKeys(key) + case BackupWizardStepName: + return m.handleBackupWizardNameKeys(msg) + case BackupWizardStepConfirm: + return m.handleBackupWizardConfirmKeys(key) + } + return m, nil +} + +func (m Model) handleBackupWizardVolumeKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.backupVolumeIdx > 0 { + m.wizard.backupVolumeIdx-- + } + case "down", "j": + if m.wizard.backupVolumeIdx < len(m.wizard.backupVolumes)-1 { + m.wizard.backupVolumeIdx++ + } + case "enter": + if len(m.wizard.backupVolumes) > 0 { + m.wizard.step = BackupWizardStepType + } + case "esc": + m.mode = TableView + m.wizard = WizardData{} + } + return m, nil +} + +func (m Model) handleBackupWizardTypeKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.backupTypeIdx > 0 { + m.wizard.backupTypeIdx-- + } + case "down", "j": + if m.wizard.backupTypeIdx < len(backupTypes)-1 { + m.wizard.backupTypeIdx++ + } + case "enter": + m.wizard.step = BackupWizardStepName + m.wizard.backupNameInput = "" + m.wizard.errorMsg = "" + case "esc": + m.wizard.step = BackupWizardStepVolume + } + return m, nil +} + +func (m Model) handleBackupWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + name := strings.TrimSpace(m.wizard.backupNameInput) + if name == "" { + m.wizard.errorMsg = "Le nom ne peut pas être vide." + return m, nil + } + m.wizard.errorMsg = "" + m.wizard.backupName = name + m.wizard.step = BackupWizardStepConfirm + m.wizard.backupConfirmBtnIdx = 0 + case "backspace": + if len(m.wizard.backupNameInput) > 0 { + runes := []rune(m.wizard.backupNameInput) + m.wizard.backupNameInput = string(runes[:len(runes)-1]) + } + case "esc": + m.wizard.step = BackupWizardStepType + default: + if len(msg.Runes) > 0 { + m.wizard.backupNameInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handleBackupWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.backupConfirmBtnIdx = 0 + case "right", "l": + m.wizard.backupConfirmBtnIdx = 1 + case "enter": + if m.wizard.backupConfirmBtnIdx == 1 { + // Cancel + m.mode = TableView + m.wizard = WizardData{} + return m, nil + } + // Create + vol := m.wizard.backupVolumes[m.wizard.backupVolumeIdx] + volumeID := getStringValue(vol, "id", "") + region := getStringValue(vol, "region", "") + name := m.wizard.backupName + isSnapshot := m.wizard.backupTypeIdx == 0 + m.wizard.isLoading = true + if isSnapshot { + m.wizard.loadingMessage = fmt.Sprintf("Création du snapshot '%s'...", name) + } else { + m.wizard.loadingMessage = fmt.Sprintf("Création du backup '%s'...", name) + } + return m, m.createVolumeBackupOrSnapshot(volumeID, region, name, isSnapshot) + case "esc": + m.wizard.step = BackupWizardStepName + } + return m, nil +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 2d3edd46..b8bba4c5 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -131,6 +131,14 @@ const ( S3UserWizardStepConfirm ) +const ( + // Volume Backup / Snapshot wizard steps (offset by 700) + BackupWizardStepVolume WizardStep = iota + 700 // pick source volume + BackupWizardStepType // pick Snapshot or Backup + BackupWizardStepName // enter name + BackupWizardStepConfirm // confirm +) + // ProductType represents a product category type ProductType int @@ -325,6 +333,13 @@ type WizardData struct { s3UserDescInput string // Description input buffer s3UserDesc string // Confirmed description s3UserConfirmBtnIdx int // 0=Create, 1=Cancel + // Volume Backup / Snapshot wizard fields + backupVolumes []map[string]interface{} // loaded block storage volumes + backupVolumeIdx int // selected volume index + backupTypeIdx int // 0=Snapshot, 1=Backup + backupName string // confirmed name + backupNameInput string // input buffer for name + backupConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -772,8 +787,8 @@ func getStorageSubItems() []StorageSubItem { return []StorageSubItem{ {Label: "Block Storage", Product: ProductStorageBlock, Path: "/storage/block", Enabled: true}, {Label: "File Storage", Product: ProductStorageFile, Path: "/storage/file", Enabled: true}, - {Label: "Volume Backup", Product: ProductStorageBackup, Path: "/storage/backup", Enabled: false}, - {Label: "Volume Snapshot", Product: ProductStorageSnapshot, Path: "/storage/snapshot", Enabled: false}, + {Label: "Volume Backup", Product: ProductStorageBackup, Path: "/storage/backup", Enabled: true}, + {Label: "Volume Snapshot", Product: ProductStorageSnapshot, Path: "/storage/snapshot", Enabled: true}, {Label: "Object Storage", Product: ProductStorageObject, Path: "/storage/object", Enabled: true}, } } @@ -923,6 +938,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Loading regions and users...", } return m, m.fetchObjectStorageInitData() + } else if msg.product == ProductStorageBackup || msg.product == ProductStorageSnapshot { + m.mode = WizardView + m.wizard = WizardData{ + step: BackupWizardStepVolume, + isLoading: true, + loadingMessage: "Chargement des volumes...", + } + return m, m.fetchBackupVolumes() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1080,6 +1103,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case block_storage.ExecuteVolumeActionMsg: return m.handleExecuteVolumeAction(msg) + case backupVolumesLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.backupVolumes = msg.volumes + m.wizard.backupVolumeIdx = 0 + return m, nil + + case volumeBackupCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + label := "Snapshot" + if msg.backupType == "backup" { + label = "Backup" + } + m.notification = fmt.Sprintf("✅ %s '%s' créé avec succès", label, msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + reloadPath := "/storage/snapshot" + if msg.backupType == "backup" { + reloadPath = "/storage/backup" + } + return m, tea.Batch( + m.fetchDataForPath(reloadPath), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case file_storage.ExecuteFileShareActionMsg: return m.handleExecuteFileShareAction(msg) @@ -1572,7 +1630,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 600 { + if m.wizard.step >= 700 { + // Backup/Snapshot wizard + titleText = " 💾 Create Volume Backup / Snapshot " + } else if m.wizard.step >= 600 { // S3 User wizard titleText = " 👤 Create S3 User " } else if m.wizard.step >= 500 { @@ -2506,8 +2567,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 600 { - // S3 User wizard + if m.wizard.step >= 700 { + // Backup/Snapshot wizard + steps = append(steps, "Volume", "Type", "Nom", "Confirmer") + stepMapping = append(stepMapping, BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm) + } else if m.wizard.step >= 600 { steps = append(steps, "Description", "Confirm") stepMapping = append(stepMapping, S3UserWizardStepDescription, S3UserWizardStepConfirm) } else if m.wizard.step >= 500 { @@ -2687,6 +2751,9 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderS3UserWizardDescStep(width)) case S3UserWizardStepConfirm: content.WriteString(m.renderS3UserWizardConfirmStep(width)) + // Volume Backup / Snapshot wizard steps + case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: + content.WriteString(m.renderBackupWizard(width)) } return content.String() } @@ -4860,6 +4927,14 @@ func (m Model) renderFooter() string { help = "Type description • Enter: Continue • Esc: Cancel" } else if m.wizard.step == S3UserWizardStepConfirm { help = "←→: Select • Enter: Confirm • Esc: Back" + } else if m.wizard.step == BackupWizardStepVolume { + help = "↑↓: Navigate • Enter: Select • Esc: Cancel" + } else if m.wizard.step == BackupWizardStepType { + help = "↑↓: Navigate • Enter: Select • ←: Back • Esc: Cancel" + } else if m.wizard.step == BackupWizardStepName { + help = "Type name • Enter: Continue • ←: Back • Esc: Cancel" + } else if m.wizard.step == BackupWizardStepConfirm { + help = "←→: Select • Enter: Confirm • Esc: Cancel" } else { help = "↑↓: Navigate • d: Debug • Enter: Select • ←: Back • Esc: Cancel" } @@ -5665,13 +5740,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -5689,7 +5764,9 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 500 { + if m.wizard.step >= 700 { + returnPath = "/storage/backup" + } else if m.wizard.step >= 500 { returnPath = "/storage/object" } else if m.wizard.step >= 400 { returnPath = "/storage/file" @@ -5845,6 +5922,8 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleS3UserWizardDescKeys(msg) case S3UserWizardStepConfirm: return m.handleS3UserWizardConfirmKeys(key) + case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: + return m.handleBackupWizardKeys(msg) } return m, nil } From d9acf49a1f39c8dd211daf88607e3e916de07dff Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 30 Apr 2026 13:26:35 +0000 Subject: [PATCH 23/55] feat(browser): added edit settings for volume backup and snapshot Signed-off-by: olivier dubo --- internal/services/browser/backup_api.go | 208 +++++++++++ internal/services/browser/backup_wizard.go | 8 +- internal/services/browser/manager.go | 129 ++++++- .../views/block_storage/backup_detail.go | 343 ++++++++++++++++++ .../views/block_storage/snapshot_detail.go | 249 +++++++++++++ internal/services/browser/views/styles.go | 4 + 6 files changed, 938 insertions(+), 3 deletions(-) create mode 100644 internal/services/browser/views/block_storage/backup_detail.go create mode 100644 internal/services/browser/views/block_storage/snapshot_detail.go diff --git a/internal/services/browser/backup_api.go b/internal/services/browser/backup_api.go index 429f1222..0536a2d9 100644 --- a/internal/services/browser/backup_api.go +++ b/internal/services/browser/backup_api.go @@ -9,9 +9,12 @@ package browser import ( "fmt" "net/url" + "strconv" + "time" tea "github.com/charmbracelet/bubbletea" httpLib "github.com/ovh/ovhcloud-cli/internal/http" + block_storage "github.com/ovh/ovhcloud-cli/internal/services/browser/views/block_storage" ) type volumeBackupCreatedMsg struct { @@ -75,3 +78,208 @@ type backupVolumesLoadedMsg struct { volumes []map[string]interface{} err error } + +// ─── Snapshot actions ───────────────────────────────────────────────────────── + +type snapshotActionDoneMsg struct { + action int + name string + err error +} + +func (m Model) executeSnapshotAction(msg block_storage.ExecuteSnapshotActionMsg) tea.Cmd { + switch msg.Action { + case block_storage.SnapshotActionDelete: + return m.deleteSnapshot(msg.Snapshot) + case block_storage.SnapshotActionCreateVolume: + return m.createVolumeFromSnapshot(msg.Snapshot, msg.VolumeName, msg.VolumeSize) + } + return nil +} + +func (m Model) deleteSnapshot(snapshot map[string]interface{}) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return snapshotActionDoneMsg{action: block_storage.SnapshotActionDelete, err: fmt.Errorf("no cloud project selected")} + } + id := fmt.Sprintf("%v", snapshot["id"]) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/snapshot/%s", m.cloudProject, url.PathEscape(id)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return snapshotActionDoneMsg{action: block_storage.SnapshotActionDelete, err: err} + } + name := fmt.Sprintf("%v", snapshot["name"]) + return snapshotActionDoneMsg{action: block_storage.SnapshotActionDelete, name: name} + } +} + +func (m Model) createVolumeFromSnapshot(snapshot map[string]interface{}, volName, volSize string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return snapshotActionDoneMsg{action: block_storage.SnapshotActionCreateVolume, err: fmt.Errorf("no cloud project selected")} + } + snapshotID := fmt.Sprintf("%v", snapshot["id"]) + sourceVolumeID := fmt.Sprintf("%v", snapshot["volumeId"]) + region := fmt.Sprintf("%v", snapshot["region"]) + sizeInt, _ := strconv.Atoi(volSize) + if sizeInt <= 0 { + sizeInt = 10 + } + // Fetch source volume type so we use the correct type + volType := "classic" + if sourceVolumeID != "" && sourceVolumeID != "" { + var sourceVol map[string]interface{} + srcEndpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(sourceVolumeID)) + if err := httpLib.Client.Get(srcEndpoint, &sourceVol); err == nil { + if t, ok := sourceVol["type"].(string); ok && t != "" { + volType = t + } + } + } + body := map[string]interface{}{ + "name": volName, + "region": region, + "size": sizeInt, + "type": volType, + "snapshotId": snapshotID, + } + var result map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume", m.cloudProject) + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return snapshotActionDoneMsg{action: block_storage.SnapshotActionCreateVolume, err: err} + } + return snapshotActionDoneMsg{action: block_storage.SnapshotActionCreateVolume, name: volName} + } +} + +// ─── Backup actions ─────────────────────────────────────────────────────────── + +type backupActionDoneMsg struct { + action int + name string + err error +} + +func (m Model) executeBackupAction(msg block_storage.ExecuteBackupActionMsg) tea.Cmd { + switch msg.Action { + case block_storage.BackupActionDelete: + return m.deleteBackup(msg.Backup) + case block_storage.BackupActionRestore: + return m.restoreBackup(msg.Backup, msg.VolumeID) + case block_storage.BackupActionCreateVolume: + return m.createVolumeFromBackup(msg.Backup, msg.VolumeName, msg.VolumeSize) + } + return nil +} + +func (m Model) deleteBackup(backup map[string]interface{}) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return backupActionDoneMsg{action: block_storage.BackupActionDelete, err: fmt.Errorf("no cloud project selected")} + } + id := fmt.Sprintf("%v", backup["id"]) + region := fmt.Sprintf("%v", backup["region"]) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeBackup/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(id)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return backupActionDoneMsg{action: block_storage.BackupActionDelete, err: err} + } + name := fmt.Sprintf("%v", backup["name"]) + return backupActionDoneMsg{action: block_storage.BackupActionDelete, name: name} + } +} + +func (m Model) restoreBackup(backup map[string]interface{}, volumeID string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return backupActionDoneMsg{action: block_storage.BackupActionRestore, err: fmt.Errorf("no cloud project selected")} + } + id := fmt.Sprintf("%v", backup["id"]) + region := fmt.Sprintf("%v", backup["region"]) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeBackup/%s/restore", + m.cloudProject, url.PathEscape(region), url.PathEscape(id)) + body := map[string]interface{}{"volumeId": volumeID} + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return backupActionDoneMsg{action: block_storage.BackupActionRestore, err: err} + } + name := fmt.Sprintf("%v", backup["name"]) + return backupActionDoneMsg{action: block_storage.BackupActionRestore, name: name} + } +} + +func (m Model) createVolumeFromBackup(backup map[string]interface{}, volName, volSize string) tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return backupActionDoneMsg{action: block_storage.BackupActionCreateVolume, err: fmt.Errorf("no cloud project selected")} + } + id := fmt.Sprintf("%v", backup["id"]) + region := fmt.Sprintf("%v", backup["region"]) + sourceVolumeID := fmt.Sprintf("%v", backup["volumeId"]) + sizeInt, _ := strconv.Atoi(volSize) + if sizeInt <= 0 { + sizeInt = 10 + } + // Fetch source volume type to preserve it + volType := "classic" + if sourceVolumeID != "" && sourceVolumeID != "" { + var sourceVol map[string]interface{} + srcEndpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(sourceVolumeID)) + if err := httpLib.Client.Get(srcEndpoint, &sourceVol); err == nil { + if t, ok := sourceVol["type"].(string); ok && t != "" { + volType = t + } + } + } + // Step 1: create a new empty volume + volBody := map[string]interface{}{ + "name": volName, + "region": region, + "size": sizeInt, + "type": volType, + } + var newVol map[string]interface{} + volEndpoint := fmt.Sprintf("/v1/cloud/project/%s/volume", m.cloudProject) + if err := httpLib.Client.Post(volEndpoint, volBody, &newVol); err != nil { + return backupActionDoneMsg{action: block_storage.BackupActionCreateVolume, err: fmt.Errorf("failed to create volume: %w", err)} + } + newVolID := fmt.Sprintf("%v", newVol["id"]) + // Wait for the new volume to become available before restoring + volDetailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(newVolID)) + for i := 0; i < 60; i++ { + time.Sleep(3 * time.Second) + var volStatus map[string]interface{} + if err := httpLib.Client.Get(volDetailEndpoint, &volStatus); err == nil { + if status, _ := volStatus["status"].(string); status == "available" { + break + } + } + } + // Step 2: restore backup onto the new volume + restoreEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/volumeBackup/%s/restore", + m.cloudProject, url.PathEscape(region), url.PathEscape(id)) + restoreBody := map[string]interface{}{"volumeId": newVolID} + var restoreResult map[string]interface{} + if err := httpLib.Client.Post(restoreEndpoint, restoreBody, &restoreResult); err != nil { + return backupActionDoneMsg{action: block_storage.BackupActionCreateVolume, err: fmt.Errorf("volume created but restore failed: %w", err)} + } + return backupActionDoneMsg{action: block_storage.BackupActionCreateVolume, name: volName} + } +} + +// fetchVolumesForRegion fetches volumes in a given region for the restore picker. +func (m Model) fetchVolumesForRegion(region string) tea.Cmd { + return func() tea.Msg { + msg := m.fetchBlockStorageData() + if msg.err != nil { + return block_storage.BackupVolumesLoadedMsg{Volumes: nil} + } + // Filter to same region + var filtered []map[string]interface{} + for _, v := range msg.data { + if fmt.Sprintf("%v", v["region"]) == region { + filtered = append(filtered, v) + } + } + return block_storage.BackupVolumesLoadedMsg{Volumes: filtered} + } +} diff --git a/internal/services/browser/backup_wizard.go b/internal/services/browser/backup_wizard.go index 0014551f..e7e72336 100644 --- a/internal/services/browser/backup_wizard.go +++ b/internal/services/browser/backup_wizard.go @@ -117,13 +117,17 @@ func (m Model) renderBackupWizardTypeStep(width int) string { func (m Model) renderBackupWizardNameStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) backupTypeName := backupTypes[m.wizard.backupTypeIdx] content.WriteString(titleStyle.Render(fmt.Sprintf("Nommez votre %s :", strings.ToLower(backupTypeName))) + "\n\n") - content.WriteString(" " + inputStyle.Render(m.wizard.backupNameInput+"▌") + "\n\n") + content.WriteString(inputStyle.Render(m.wizard.backupNameInput+"▌") + "\n\n") if m.wizard.errorMsg != "" { content.WriteString(errStyle.Render(" ❌ "+m.wizard.errorMsg) + "\n\n") diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index b8bba4c5..c4d6088c 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -389,6 +389,10 @@ type Model struct { detailRefreshName string // Block Storage detail view volumeDetailView *block_storage.DetailView + // Snapshot detail view + snapshotDetailView *block_storage.SnapshotDetailView + // Backup detail view + backupDetailView *block_storage.BackupDetailView // File Storage detail view fileShareDetailView *file_storage.DetailView // Object Storage detail view @@ -1223,6 +1227,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = TableView return m, nil } + if m.mode == DetailView && m.currentProduct == ProductStorageSnapshot { + m.snapshotDetailView = nil + m.mode = TableView + return m, nil + } + if m.mode == DetailView && m.currentProduct == ProductStorageBackup { + m.backupDetailView = nil + m.mode = TableView + return m, nil + } if m.mode == DetailView && m.currentProduct == ProductStorageFile { m.fileShareDetailView = nil m.mode = TableView @@ -1243,6 +1257,77 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case volumeActionDoneMsg: return m.handleVolumeActionDone(msg) + case block_storage.ExecuteSnapshotActionMsg: + return m, m.executeSnapshotAction(msg) + + case snapshotActionDoneMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.snapshotDetailView = nil + m.mode = LoadingView + if msg.action == block_storage.SnapshotActionCreateVolume { + m.notification = fmt.Sprintf("✅ Volume '%s' créé depuis le snapshot", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + // Navigate to Block Storage so handleDataLoaded matches the product + m.currentProduct = ProductStorageBlock + m.storageSubIdx = 0 // Block Storage is index 0 in getStorageSubItems() + return m, tea.Batch( + m.fetchDataForPath("/storage/block"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + m.notification = fmt.Sprintf("✅ Snapshot '%s' supprimé", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Batch( + m.fetchDataForPath("/storage/snapshot"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case block_storage.ExecuteBackupActionMsg: + return m, m.executeBackupAction(msg) + + case block_storage.LoadBackupRestoreVolumesMsg: + region := fmt.Sprintf("%v", msg.Backup["region"]) + return m, m.fetchVolumesForRegion(region) + + case block_storage.BackupVolumesLoadedMsg: + if m.backupDetailView != nil { + m.backupDetailView.SetRestoreVolumes(msg.Volumes) + } + return m, nil + + case backupActionDoneMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.backupDetailView = nil + m.mode = LoadingView + if msg.action == block_storage.BackupActionCreateVolume { + m.notification = fmt.Sprintf("✅ Volume '%s' créé depuis le backup", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.currentProduct = ProductStorageBlock + m.storageSubIdx = 0 + return m, tea.Batch( + m.fetchDataForPath("/storage/block"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + action := fmt.Sprintf("Backup '%s' supprimé", msg.name) + if msg.action == block_storage.BackupActionRestore { + action = fmt.Sprintf("Backup '%s' restauré avec succès", msg.name) + } + m.notification = "✅ " + action + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Batch( + m.fetchDataForPath("/storage/backup"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case refreshBlockStorageMsg: return m, m.fetchDataForPath("/storage/block") @@ -1691,6 +1776,10 @@ func (m Model) renderContentBox(width int) string { titleText = m.objectUserDetailView.Title() } else if m.mode == DetailView && m.currentProduct == ProductStorageObject && m.objectDetailView != nil { titleText = m.objectDetailView.Title() + } else if m.mode == DetailView && m.currentProduct == ProductStorageSnapshot && m.snapshotDetailView != nil { + titleText = m.snapshotDetailView.Title() + } else if m.mode == DetailView && m.currentProduct == ProductStorageBackup && m.backupDetailView != nil { + titleText = m.backupDetailView.Title() } else if m.mode == DetailView && m.currentItemName != "" { titleText = fmt.Sprintf(" %s %s > %s ", currentNav.Icon, currentNav.Label, m.currentItemName) } else { @@ -3998,7 +4087,7 @@ func (m Model) renderVolumeWizardNameStep(width int) string { Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#00FF7F")). Padding(0, 1). - Width(40) + Width(50) content.WriteString(inputStyle.Render(m.wizard.volumeNameInput+"▌") + "\n\n") helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) @@ -4437,6 +4526,16 @@ func (m Model) renderDetailView(width int) string { return m.volumeDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductStorageSnapshot: + if m.snapshotDetailView != nil { + return m.snapshotDetailView.Render(width, 0) + } + return m.renderGenericDetail(width) + case ProductStorageBackup: + if m.backupDetailView != nil { + return m.backupDetailView.Render(width, 0) + } + return m.renderGenericDetail(width) case ProductStorageFile: if m.fileShareDetailView != nil { return m.fileShareDetailView.Render(width, 0) @@ -4875,6 +4974,10 @@ func (m Model) renderFooter() string { help = m.objectUserDetailView.HelpText() } else if m.currentProduct == ProductStorageObject && m.objectDetailView != nil { help = m.objectDetailView.HelpText() + } else if m.currentProduct == ProductStorageSnapshot && m.snapshotDetailView != nil { + help = m.snapshotDetailView.HelpText() + } else if m.currentProduct == ProductStorageBackup && m.backupDetailView != nil { + help = m.backupDetailView.HelpText() } else if m.actionConfirm { help = "Enter: Confirm Action • Esc: Cancel" } else { @@ -5028,6 +5131,18 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + // Delegate to snapshot detail view + if m.mode == DetailView && m.currentProduct == ProductStorageSnapshot && m.snapshotDetailView != nil { + cmd := m.snapshotDetailView.HandleKey(msg) + return m, cmd + } + + // Delegate to backup detail view + if m.mode == DetailView && m.currentProduct == ProductStorageBackup && m.backupDetailView != nil { + cmd := m.backupDetailView.HandleKey(msg) + return m, cmd + } + // Delegate to file storage detail view when in DetailView for ProductStorageFile if m.mode == DetailView && m.currentProduct == ProductStorageFile && m.fileShareDetailView != nil { cmd := m.fileShareDetailView.HandleKey(msg) @@ -5348,6 +5463,18 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.fileShareDetailView = file_storage.NewDetailView(ctx, m.detailData) return m, nil } + // If viewing a snapshot, init snapshot detail view + if m.currentProduct == ProductStorageSnapshot { + ctx := &views.Context{Width: m.width, Height: m.height} + m.snapshotDetailView = block_storage.NewSnapshotDetailView(ctx, m.detailData) + return m, nil + } + // If viewing a backup, init backup detail view + if m.currentProduct == ProductStorageBackup { + ctx := &views.Context{Width: m.width, Height: m.height} + m.backupDetailView = block_storage.NewBackupDetailView(ctx, m.detailData) + return m, nil + } if m.currentProduct == ProductStorageObject { if m.objectStorageTabIdx == 1 { // Open user detail view — use objectStorageUsers for full data diff --git a/internal/services/browser/views/block_storage/backup_detail.go b/internal/services/browser/views/block_storage/backup_detail.go new file mode 100644 index 00000000..733d2126 --- /dev/null +++ b/internal/services/browser/views/block_storage/backup_detail.go @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package block_storage + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +const ( + BackupActionDelete = iota + BackupActionRestore // restore onto existing volume + BackupActionCreateVolume // create new volume then restore +) + +var backupActionLabels = []string{"Delete", "Restore to Volume", "Create Volume"} + +const ( + backupSubNone = 0 + backupSubPicker = 1 // existing-volume picker for Restore + backupSubName = 2 // name input for Create Volume + backupSubSize = 3 // size input for Create Volume +) + +// ExecuteBackupActionMsg is dispatched when a backup action is confirmed. +type ExecuteBackupActionMsg struct { + Backup map[string]interface{} + Action int + VolumeID string // for Restore action + VolumeName string // for CreateVolume action + VolumeSize string // for CreateVolume action +} + +// BackupVolumesLoadedMsg is sent by the manager after loading volumes for restore picker. +type BackupVolumesLoadedMsg struct { + Volumes []map[string]interface{} +} + +// BackupDetailView displays a volume backup with Delete, Restore and Create Volume actions. +type BackupDetailView struct { + views.BaseView + backup map[string]interface{} + selectedAction int + confirmMode bool + subMenu int + restoreVolumes []map[string]interface{} + restoreIdx int + nameInput string + sizeInput string +} + +func NewBackupDetailView(ctx *views.Context, backup map[string]interface{}) *BackupDetailView { + return &BackupDetailView{ + BaseView: views.NewBaseView(ctx), + backup: backup, + } +} + +// SetRestoreVolumes is called by the manager after volumes are loaded. +func (v *BackupDetailView) SetRestoreVolumes(volumes []map[string]interface{}) { + v.restoreVolumes = volumes + v.restoreIdx = 0 +} + +func (v *BackupDetailView) Render(width, height int) string { + var content strings.Builder + + id := getString(v.backup, "id") + name := getString(v.backup, "name") + status := getString(v.backup, "status") + region := getString(v.backup, "region") + volumeId := getString(v.backup, "volumeId") + created := getString(v.backup, "creationDate") + if len(created) > 19 { + created = created[:19] + } + size := getSizeStr(v.backup) + + var info strings.Builder + info.WriteString(views.RenderKeyValue("ID", id) + "\n") + info.WriteString(views.RenderKeyValue("Name", name) + "\n") + info.WriteString(views.RenderKeyValue("Status", views.RenderStatus(status)) + "\n") + info.WriteString(views.RenderKeyValue("Region", region) + "\n") + info.WriteString(views.RenderKeyValue("Size", size+" GB") + "\n") + info.WriteString(views.RenderKeyValue("Source Volume", volumeId) + "\n") + info.WriteString(views.RenderKeyValue("Created", created) + "\n") + content.WriteString(views.RenderBox("Backup Information", info.String(), width-4)) + content.WriteString("\n\n") + + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", v.renderActions(), width-4)) + return content.String() +} + +func (v *BackupDetailView) renderActions() string { + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + + if v.subMenu == backupSubName { + return views.StyleStatusWarning.Render("Nom du nouveau volume :") + "\n" + + inputStyle.Render(v.nameInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Suivant • Esc: Annuler") + } + + if v.subMenu == backupSubSize { + currentSize := getSizeStr(v.backup) + return views.StyleStatusWarning.Render(fmt.Sprintf("Taille en GB (backup: %s GB, doit être ≥) :", currentSize)) + "\n" + + inputStyle.Render(v.sizeInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Créer • Esc: Retour") + } + + if v.subMenu == backupSubPicker { + if len(v.restoreVolumes) == 0 { + return views.StyleStatusWarning.Render("⏳ Chargement des volumes...") + "\n\n" + + views.StyleFooter.Render("Esc: Annuler") + } + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + var sb strings.Builder + sb.WriteString(views.StyleStatusWarning.Render("⚠️ Choisissez le volume cible (les données seront écrasées) :") + "\n\n") + maxVisible := 8 + startIdx := 0 + if v.restoreIdx >= maxVisible { + startIdx = v.restoreIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(v.restoreVolumes) { + endIdx = len(v.restoreVolumes) + } + if startIdx > 0 { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d au-dessus)", startIdx)) + "\n") + } + for i := startIdx; i < endIdx; i++ { + vol := v.restoreVolumes[i] + label := fmt.Sprintf("%-28s %s %s GB", getString(vol, "name"), getString(vol, "region"), getSizeStr(vol)) + if i == v.restoreIdx { + sb.WriteString(selectedStyle.Render(" ▶ "+label) + "\n") + } else { + sb.WriteString(itemStyle.Render(" "+label) + "\n") + } + } + if endIdx < len(v.restoreVolumes) { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d en-dessous)", len(v.restoreVolumes)-endIdx)) + "\n") + } + sb.WriteString("\n" + views.StyleFooter.Render("↑↓: Navigate • Enter: Confirm Restore • Esc: Annuler")) + return sb.String() + } + + var parts []string + for i, label := range backupActionLabels { + var style lipgloss.Style + if i == v.selectedAction { + if label == "Delete" { + style = views.StyleButtonDangerSelected + } else { + style = views.StyleButtonSelected + } + } else if label == "Delete" { + style = views.StyleButtonDanger + } else { + style = views.StyleButton + } + parts = append(parts, style.Render("["+label+"]")) + } + result := strings.Join(parts, " ") + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render("⚠️ Press Enter to confirm Delete, Escape to cancel") + } + return result +} + +func (v *BackupDetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + // Name input for Create Volume + if v.subMenu == backupSubName { + switch msg.Type { + case tea.KeyEscape: + v.subMenu = backupSubNone + v.nameInput = "" + case tea.KeyEnter: + if strings.TrimSpace(v.nameInput) != "" { + v.subMenu = backupSubSize + if v.sizeInput == "" { + v.sizeInput = getSizeStr(v.backup) + } + } + case tea.KeyBackspace: + if len(v.nameInput) > 0 { + runes := []rune(v.nameInput) + v.nameInput = string(runes[:len(runes)-1]) + } + case tea.KeyRunes: + v.nameInput += string(msg.Runes) + } + return nil + } + + // Size input for Create Volume + if v.subMenu == backupSubSize { + switch msg.Type { + case tea.KeyEscape: + v.subMenu = backupSubName + case tea.KeyEnter: + if v.sizeInput != "" { + backup := v.backup + name := v.nameInput + size := v.sizeInput + v.subMenu = backupSubNone + v.nameInput = "" + v.sizeInput = "" + return func() tea.Msg { + return ExecuteBackupActionMsg{ + Backup: backup, + Action: BackupActionCreateVolume, + VolumeName: name, + VolumeSize: size, + } + } + } + case tea.KeyBackspace: + if len(v.sizeInput) > 0 { + v.sizeInput = v.sizeInput[:len(v.sizeInput)-1] + } + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + v.sizeInput += string(r) + } + } + } + return nil + } + + if v.subMenu == backupSubPicker { + switch key { + case "up", "k": + if v.restoreIdx > 0 { + v.restoreIdx-- + } + case "down", "j": + if v.restoreIdx < len(v.restoreVolumes)-1 { + v.restoreIdx++ + } + case "enter": + if len(v.restoreVolumes) > 0 { + backup := v.backup + volID := getString(v.restoreVolumes[v.restoreIdx], "id") + v.subMenu = backupSubNone + return func() tea.Msg { + return ExecuteBackupActionMsg{ + Backup: backup, + Action: BackupActionRestore, + VolumeID: volID, + } + } + } + case "esc": + v.subMenu = backupSubNone + } + return nil + } + + switch key { + case "left", "h": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + case "right", "l": + if v.selectedAction < len(backupActionLabels)-1 { + v.selectedAction++ + v.confirmMode = false + } + case "enter": + if v.confirmMode { + v.confirmMode = false + backup := v.backup + return func() tea.Msg { + return ExecuteBackupActionMsg{Backup: backup, Action: BackupActionDelete} + } + } + switch v.selectedAction { + case BackupActionDelete: + v.confirmMode = true + case BackupActionRestore: + // Signal manager to load volumes, show picker (empty, loading state) + v.subMenu = backupSubPicker + v.restoreVolumes = nil + v.restoreIdx = 0 + backup := v.backup + return func() tea.Msg { + return LoadBackupRestoreVolumesMsg{Backup: backup} + } + case BackupActionCreateVolume: + v.subMenu = backupSubName + v.nameInput = getString(v.backup, "name") + v.sizeInput = getSizeStr(v.backup) + } + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + return func() tea.Msg { return views.GoBackMsg{} } + } + return nil +} + +// LoadBackupRestoreVolumesMsg asks the manager to fetch volumes for the restore picker. +type LoadBackupRestoreVolumesMsg struct { + Backup map[string]interface{} +} + +func (v *BackupDetailView) Title() string { + return fmt.Sprintf(" 💾 Backup > %s ", getString(v.backup, "name")) +} + +func (v *BackupDetailView) HelpText() string { + switch v.subMenu { + case backupSubPicker: + return "↑↓: Navigate • Enter: Restore • Esc: Cancel" + case backupSubName: + return "Type name • Enter: Suivant • Esc: Cancel" + case backupSubSize: + return "Type size in GB • Enter: Créer • Esc: Retour" + } + if v.confirmMode { + return "Enter: Confirm Delete • Esc: Cancel" + } + return "←→: Select Action • Enter: Execute • Esc: Back • q: Quit" +} diff --git a/internal/services/browser/views/block_storage/snapshot_detail.go b/internal/services/browser/views/block_storage/snapshot_detail.go new file mode 100644 index 00000000..7c2fb7c5 --- /dev/null +++ b/internal/services/browser/views/block_storage/snapshot_detail.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package block_storage + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ovh/ovhcloud-cli/internal/services/browser/views" +) + +const ( + SnapshotActionDelete = iota + SnapshotActionCreateVolume +) + +var snapshotActionLabels = []string{"Delete", "Create Volume"} + +const ( + snapshotSubNone = 0 + snapshotSubName = 1 + snapshotSubSize = 2 +) + +// ExecuteSnapshotActionMsg is dispatched when snapshot action is confirmed. +type ExecuteSnapshotActionMsg struct { + Snapshot map[string]interface{} + Action int + VolumeName string + VolumeSize string // GB as string +} + +// SnapshotDetailView displays a volume snapshot with Delete and Create Volume actions. +type SnapshotDetailView struct { + views.BaseView + snapshot map[string]interface{} + selectedAction int + confirmMode bool + subMenu int + nameInput string + sizeInput string +} + +func NewSnapshotDetailView(ctx *views.Context, snapshot map[string]interface{}) *SnapshotDetailView { + return &SnapshotDetailView{ + BaseView: views.NewBaseView(ctx), + snapshot: snapshot, + } +} + +func (v *SnapshotDetailView) Render(width, height int) string { + var content strings.Builder + + id := getString(v.snapshot, "id") + name := getString(v.snapshot, "name") + status := getString(v.snapshot, "status") + region := getString(v.snapshot, "region") + volumeId := getString(v.snapshot, "volumeId") + created := getString(v.snapshot, "creationDate") + if len(created) > 19 { + created = created[:19] + } + size := getSizeStr(v.snapshot) + + var info strings.Builder + info.WriteString(views.RenderKeyValue("ID", id) + "\n") + info.WriteString(views.RenderKeyValue("Name", name) + "\n") + info.WriteString(views.RenderKeyValue("Status", views.RenderStatus(status)) + "\n") + info.WriteString(views.RenderKeyValue("Region", region) + "\n") + info.WriteString(views.RenderKeyValue("Size", size+" GB") + "\n") + info.WriteString(views.RenderKeyValue("Source Volume", volumeId) + "\n") + info.WriteString(views.RenderKeyValue("Created", created) + "\n") + content.WriteString(views.RenderBox("Snapshot Information", info.String(), width-4)) + content.WriteString("\n\n") + + content.WriteString(views.RenderBox("Actions (←/→ to navigate, Enter to execute)", v.renderActions(size), width-4)) + return content.String() +} + +func (v *SnapshotDetailView) renderActions(currentSize string) string { + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1). + Width(40) + + switch v.subMenu { + case snapshotSubName: + return views.StyleStatusWarning.Render("Nom du nouveau volume :") + "\n" + + inputStyle.Render(v.nameInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Suivant • Esc: Annuler") + case snapshotSubSize: + return views.StyleStatusWarning.Render(fmt.Sprintf("Taille en GB (actuelle: %s GB, doit être ≥) :", currentSize)) + "\n" + + inputStyle.Render(v.sizeInput+"▌") + "\n\n" + + views.StyleFooter.Render("Enter: Créer • Esc: Retour") + } + + var parts []string + for i, label := range snapshotActionLabels { + var style lipgloss.Style + // Disable "Create Volume" if snapshot is not available + if i == SnapshotActionCreateVolume && getString(v.snapshot, "status") != "available" { + style = views.StyleButtonDisabled + } else if i == v.selectedAction { + if label == "Delete" { + style = views.StyleButtonDangerSelected + } else { + style = views.StyleButtonSelected + } + } else if label == "Delete" { + style = views.StyleButtonDanger + } else { + style = views.StyleButton + } + parts = append(parts, style.Render("["+label+"]")) + } + result := strings.Join(parts, " ") + if getString(v.snapshot, "status") != "available" { + result += "\n\n" + views.StyleStatusWarning.Render( + fmt.Sprintf("⚠️ Snapshot status: %s — Create Volume requires status: available", getString(v.snapshot, "status"))) + } + if v.confirmMode { + result += "\n\n" + views.StyleStatusWarning.Render("⚠️ Press Enter to confirm Delete, Escape to cancel") + } + return result +} + +func (v *SnapshotDetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + // Name input step + if v.subMenu == snapshotSubName { + switch msg.Type { + case tea.KeyEscape: + v.subMenu = snapshotSubNone + v.nameInput = "" + case tea.KeyEnter: + if strings.TrimSpace(v.nameInput) != "" { + v.subMenu = snapshotSubSize + if v.sizeInput == "" { + v.sizeInput = getSizeStr(v.snapshot) + } + } + case tea.KeyBackspace: + if len(v.nameInput) > 0 { + runes := []rune(v.nameInput) + v.nameInput = string(runes[:len(runes)-1]) + } + case tea.KeyRunes: + v.nameInput += string(msg.Runes) + } + return nil + } + + // Size input step + if v.subMenu == snapshotSubSize { + switch msg.Type { + case tea.KeyEscape: + v.subMenu = snapshotSubName + case tea.KeyEnter: + if v.sizeInput != "" { + snap := v.snapshot + name := v.nameInput + size := v.sizeInput + v.subMenu = snapshotSubNone + v.nameInput = "" + v.sizeInput = "" + return func() tea.Msg { + return ExecuteSnapshotActionMsg{ + Snapshot: snap, + Action: SnapshotActionCreateVolume, + VolumeName: name, + VolumeSize: size, + } + } + } + case tea.KeyBackspace: + if len(v.sizeInput) > 0 { + v.sizeInput = v.sizeInput[:len(v.sizeInput)-1] + } + case tea.KeyRunes: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' { + v.sizeInput += string(r) + } + } + } + return nil + } + + switch key { + case "left", "h": + if v.selectedAction > 0 { + v.selectedAction-- + v.confirmMode = false + } + case "right", "l": + if v.selectedAction < len(snapshotActionLabels)-1 { + v.selectedAction++ + v.confirmMode = false + } + case "enter": + if v.confirmMode { + v.confirmMode = false + snap := v.snapshot + return func() tea.Msg { + return ExecuteSnapshotActionMsg{Snapshot: snap, Action: SnapshotActionDelete} + } + } + switch v.selectedAction { + case SnapshotActionDelete: + v.confirmMode = true + case SnapshotActionCreateVolume: + if getString(v.snapshot, "status") != "available" { + return nil // disabled + } + v.subMenu = snapshotSubName + v.nameInput = getString(v.snapshot, "name") + v.sizeInput = getSizeStr(v.snapshot) + } + case "esc": + if v.confirmMode { + v.confirmMode = false + return nil + } + return func() tea.Msg { return views.GoBackMsg{} } + } + return nil +} + +func (v *SnapshotDetailView) Title() string { + return fmt.Sprintf(" 📸 Snapshot > %s ", getString(v.snapshot, "name")) +} + +func (v *SnapshotDetailView) HelpText() string { + if v.subMenu != snapshotSubNone { + return "Type value • Enter: Confirm • Esc: Cancel" + } + if v.confirmMode { + return "Enter: Confirm Delete • Esc: Cancel" + } + return "←→: Select Action • Enter: Execute • Esc: Back • q: Quit" +} diff --git a/internal/services/browser/views/styles.go b/internal/services/browser/views/styles.go index f153ecb4..ce0fbf25 100644 --- a/internal/services/browser/views/styles.go +++ b/internal/services/browser/views/styles.go @@ -109,6 +109,10 @@ var ( Foreground(ColorMuted). Padding(0, 1) + StyleButtonDisabled = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444444")). + Padding(0, 1) + StyleButtonDanger = lipgloss.NewStyle(). Foreground(ColorDanger). Padding(0, 1) From 5827dc0072e4758eeb640e44544268c8bfa8f708 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 30 Apr 2026 14:59:14 +0000 Subject: [PATCH 24/55] feat(browser): fixed block storage location Signed-off-by: olivier dubo --- internal/services/browser/api.go | 82 ++++++++++++++++++++++++---- internal/services/browser/manager.go | 55 ++++++++++++++----- 2 files changed, 112 insertions(+), 25 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index dbe701a7..b46504c5 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -938,8 +938,9 @@ func (m Model) fetchVolumeRegions() tea.Cmd { } type probeResult struct { - region string - types []string + region string + types []string + typeAZMap map[string][]string } ch := make(chan probeResult, len(regionNames)) for _, name := range regionNames { @@ -948,25 +949,40 @@ func (m Model) fetchVolumeRegions() tea.Cmd { m.cloudProject, url.PathEscape(regionName)) var rawTypes []map[string]interface{} if err := httpLib.Client.Get(typesEndpoint, &rawTypes); err != nil { - ch <- probeResult{region: regionName, types: nil} + ch <- probeResult{region: regionName} return } var types []string + typeAZMap := make(map[string][]string) for _, t := range rawTypes { - if n, ok := t["name"].(string); ok && n != "" { - types = append(types, n) + n, ok := t["name"].(string) + if !ok || n == "" || strings.HasSuffix(n, "-luks") { + continue + } + types = append(types, n) + if azRaw, ok := t["availabilityZones"].([]interface{}); ok { + var azs []string + for _, az := range azRaw { + if s, ok := az.(string); ok && s != "" { + azs = append(azs, s) + } + } + sort.Strings(azs) + typeAZMap[n] = azs } } sort.Strings(types) - ch <- probeResult{region: regionName, types: types} + ch <- probeResult{region: regionName, types: types, typeAZMap: typeAZMap} }(name) } regionTypeMap := make(map[string][]string) + regionTypeAZMap := make(map[string]map[string][]string) for range regionNames { r := <-ch if len(r.types) > 0 { regionTypeMap[r.region] = r.types + regionTypeAZMap[r.region] = r.typeAZMap } } var supported []string @@ -980,7 +996,7 @@ func (m Model) fetchVolumeRegions() tea.Cmd { if len(supported) == 0 { return volumeRegionsLoadedMsg{err: fmt.Errorf("no regions support block storage volumes in this project")} } - return volumeRegionsLoadedMsg{regionNames: supported, regionTypeMap: regionTypeMap} + return volumeRegionsLoadedMsg{regionNames: supported, regionTypeMap: regionTypeMap, regionTypeAZMap: regionTypeAZMap} } } @@ -995,13 +1011,26 @@ func (m Model) fetchVolumeTypes(region string) tea.Cmd { return volumeTypesLoadedMsg{err: fmt.Errorf("failed to fetch volume types: %w", err)} } var types []string + typeAZMap := make(map[string][]string) for _, t := range rawTypes { - if n, ok := t["name"].(string); ok && n != "" && !strings.HasSuffix(n, "-luks") { - types = append(types, n) + n, ok := t["name"].(string) + if !ok || n == "" || strings.HasSuffix(n, "-luks") { + continue + } + types = append(types, n) + if azRaw, ok := t["availabilityZones"].([]interface{}); ok { + var azs []string + for _, az := range azRaw { + if s, ok := az.(string); ok && s != "" { + azs = append(azs, s) + } + } + sort.Strings(azs) + typeAZMap[n] = azs } } sort.Strings(types) - return volumeTypesLoadedMsg{types: types} + return volumeTypesLoadedMsg{types: types, typeAZMap: typeAZMap} } } @@ -1018,7 +1047,7 @@ func (m Model) fetchVolumeAvailabilityZones(region string) tea.Cmd { var azs []string if raw, ok := regionDetail["availabilityZones"].([]interface{}); ok { for _, az := range raw { - if s, ok := az.(string); ok { + if s, ok := az.(string); ok && s != "" && s != "nova" { azs = append(azs, s) } } @@ -1054,6 +1083,23 @@ func (m Model) createVolume() tea.Cmd { func (m Model) deleteVolume(volumeId string) tea.Cmd { return func() tea.Msg { + // Check for snapshots first — the API rejects delete if any exist + snapshotsEndpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/snapshot", m.cloudProject) + var snapshots []map[string]interface{} + if err := httpLib.Client.Get(snapshotsEndpoint, &snapshots); err == nil { + var blocking []string + for _, s := range snapshots { + if vid, ok := s["volumeId"].(string); ok && vid == volumeId { + if sid, ok := s["id"].(string); ok { + blocking = append(blocking, sid) + } + } + } + if len(blocking) > 0 { + msg := fmt.Sprintf("Ce volume possède %d snapshot(s). Supprimez-les d'abord depuis l'onglet Volume Snapshots.", len(blocking)) + return volumeActionDoneMsg{action: 0, err: fmt.Errorf("%s", msg)} + } + } endpoint := fmt.Sprintf("/v1/cloud/project/%s/volume/%s", m.cloudProject, url.PathEscape(volumeId)) err := httpLib.Client.Delete(endpoint, nil) return volumeActionDoneMsg{action: 0, err: err} @@ -1092,6 +1138,7 @@ func (m Model) handleVolumeRegionsLoaded(msg volumeRegionsLoadedMsg) (tea.Model, m.wizard.regions = append(m.wizard.regions, map[string]interface{}{"name": name}) } m.wizard.volumeRegionTypeMap = msg.regionTypeMap + m.wizard.volumeRegionTypeAZMap = msg.regionTypeAZMap m.wizard.selectedIndex = 0 return m, nil } @@ -1104,6 +1151,7 @@ func (m Model) handleVolumeTypesLoaded(msg volumeTypesLoadedMsg) (tea.Model, tea return m, nil } m.wizard.volumeTypes = msg.types + m.wizard.volumeTypeAZMap = msg.typeAZMap m.wizard.selectedIndex = 0 return m, nil } @@ -1117,6 +1165,16 @@ func (m Model) handleVolumeAZLoaded(msg volumeAZLoadedMsg) (tea.Model, tea.Cmd) } m.wizard.volumeAvailabilityZones = msg.availabilityZones m.wizard.selectedIndex = 0 + // Region has no real AZ choices (only "nova" or empty) — skip this step + if len(msg.availabilityZones) == 0 { + m.wizard.volumeAvailabilityZone = "" + m.wizard.step = VolumeWizardStepSize + if m.wizard.volumeSize > 0 { + m.wizard.volumeSizeInput = fmt.Sprintf("%d", m.wizard.volumeSize) + } else { + m.wizard.volumeSizeInput = "" + } + } return m, nil } @@ -1868,7 +1926,7 @@ func createBlockStorageTable(data []map[string]interface{}, width, height int) t columns := []table.Column{ {Title: "Nom", Width: 24}, {Title: "ID", Width: 36}, - {Title: "Localisation", Width: 14}, + {Title: "Localisation", Width: 20}, {Title: "Type", Width: 14}, {Title: "Capacité", Width: 10}, {Title: "Instance", Width: 20}, diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index c4d6088c..4ea92159 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -290,6 +290,8 @@ type WizardData struct { volumeTypes []string // Available volume types for the selected region volumeRegionTypeMap map[string][]string // region name -> []type names (pre-loaded) volumeAvailabilityZones []string // Available availability zones for the region + volumeTypeAZMap map[string][]string // type name -> available AZs (for selected region) + volumeRegionTypeAZMap map[string]map[string][]string // region -> type -> AZs (pre-loaded) volumeName string // Volume name input volumeNameInput string // Input buffer for volume name volumeSize int // Volume size in GB @@ -667,14 +669,16 @@ type kubeNodePoolsLoadedMsg struct { } type volumeRegionsLoadedMsg struct { - regionNames []string - regionTypeMap map[string][]string - err error + regionNames []string + regionTypeMap map[string][]string + regionTypeAZMap map[string]map[string][]string // region -> type -> []AZs + err error } type volumeTypesLoadedMsg struct { - types []string - err error + types []string + typeAZMap map[string][]string // type -> []AZs + err error } type volumeAZLoadedMsg struct { @@ -4139,10 +4143,11 @@ func (m Model) renderVolumeWizardTypeStep(width int) string { selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) for i, vt := range m.wizard.volumeTypes { + label := volumeTypeDisplayName(vt) if i == m.wizard.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + vt)) + content.WriteString(selectedStyle.Render("▶ " + label)) } else { - content.WriteString(listStyle.Render(" " + vt)) + content.WriteString(listStyle.Render(" " + label)) } content.WriteString("\n") } @@ -4170,10 +4175,16 @@ func (m Model) renderVolumeWizardAZStep(width int) string { // Prepend a "No preference" option allItems := append([]string{"(No preference)"}, items...) for i, az := range allItems { + var label string + if i == 0 { + label = az + } else { + label = fmt.Sprintf("%s(%s)", m.wizard.selectedRegion, az) + } if i == m.wizard.selectedIndex { - content.WriteString(selectedStyle.Render("▶ " + az)) + content.WriteString(selectedStyle.Render("▶ " + label)) } else { - content.WriteString(listStyle.Render(" " + az)) + content.WriteString(listStyle.Render(" " + label)) } content.WriteString("\n") } @@ -4206,6 +4217,16 @@ func (m Model) renderVolumeWizardSizeStep(width int) string { return content.String() } +func volumeTypeDisplayName(name string) string { + words := strings.Split(name, "-") + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, " ") +} + func (m Model) volumeTypeSupportLuks() bool { // Only classic, high-speed, high-speed-gen2 have -luks variants switch m.wizard.volumeType { @@ -4307,7 +4328,7 @@ func (m Model) renderVolumeWizardConfirmStep(width int) string { azDisplay := "(No preference)" if m.wizard.volumeAvailabilityZone != "" { - azDisplay = m.wizard.volumeAvailabilityZone + azDisplay = fmt.Sprintf("%s(%s)", m.wizard.selectedRegion, m.wizard.volumeAvailabilityZone) } content.WriteString(labelStyle.Render(" Avail. Zone:") + valueStyle.Render(azDisplay) + "\n") content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.volumeSize)) + "\n") @@ -7703,6 +7724,7 @@ func (m Model) handleVolumeWizardRegionKeys(key string, msg tea.KeyMsg) (tea.Mod m.wizard.errorMsg = "" if types, ok := m.wizard.volumeRegionTypeMap[m.wizard.selectedRegion]; ok { m.wizard.volumeTypes = types + m.wizard.volumeTypeAZMap = m.wizard.volumeRegionTypeAZMap[m.wizard.selectedRegion] m.wizard.step = VolumeWizardStepType m.wizard.selectedIndex = 0 } else { @@ -7737,10 +7759,12 @@ func (m Model) handleVolumeWizardTypeKeys(key string, msg tea.KeyMsg) (tea.Model } m.wizard.volumeType = types[m.wizard.selectedIndex] m.wizard.errorMsg = "" - m.wizard.step = VolumeWizardStepAvailabilityZone m.wizard.selectedIndex = 0 + m.wizard.volumeAvailabilityZones = nil + m.wizard.volumeAvailabilityZone = "" + m.wizard.step = VolumeWizardStepAvailabilityZone m.wizard.isLoading = true - m.wizard.loadingMessage = "Loading availability zones..." + m.wizard.loadingMessage = "Chargement des zones..." return m, m.fetchVolumeAvailabilityZones(m.wizard.selectedRegion) case "left": m.wizard.step = VolumeWizardStepRegion @@ -7801,7 +7825,12 @@ func (m Model) handleVolumeWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.wizard.volumeSizeInput = m.wizard.volumeSizeInput[:len(m.wizard.volumeSizeInput)-1] } case tea.KeyLeft: - m.wizard.step = VolumeWizardStepAvailabilityZone + // Go back to AZ step only if that step was actually shown (type had AZ choices) + if len(m.wizard.volumeAvailabilityZones) > 0 { + m.wizard.step = VolumeWizardStepAvailabilityZone + } else { + m.wizard.step = VolumeWizardStepType + } m.wizard.selectedIndex = 0 case tea.KeyRunes: for _, r := range msg.Runes { From 5c62c173117f404149bcb81e1c0d5ad4429c33fe Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 07:52:57 +0000 Subject: [PATCH 25/55] feat(browser): fixed text and size in block storage and file storage Signed-off-by: olivier dubo --- internal/services/browser/api.go | 16 ++--- internal/services/browser/backup_wizard.go | 50 +++++++------- internal/services/browser/file_api.go | 8 +-- internal/services/browser/file_wizard.go | 10 ++- internal/services/browser/manager.go | 68 +++++++++---------- internal/services/browser/object_wizard.go | 46 ++++++------- .../views/block_storage/backup_detail.go | 24 +++---- .../browser/views/block_storage/detail.go | 4 +- .../views/block_storage/snapshot_detail.go | 8 +-- .../browser/views/file_storage/detail.go | 6 +- .../browser/views/object_storage/detail.go | 44 ++++++------ .../views/object_storage/user_detail.go | 6 +- 12 files changed, 147 insertions(+), 143 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index b46504c5..7ebb0f48 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1096,7 +1096,7 @@ func (m Model) deleteVolume(volumeId string) tea.Cmd { } } if len(blocking) > 0 { - msg := fmt.Sprintf("Ce volume possède %d snapshot(s). Supprimez-les d'abord depuis l'onglet Volume Snapshots.", len(blocking)) + msg := fmt.Sprintf("This volume has %d snapshot(s). Delete them first from the Volume Snapshots tab.", len(blocking)) return volumeActionDoneMsg{action: 0, err: fmt.Errorf("%s", msg)} } } @@ -1924,14 +1924,14 @@ func createGenericTable(data []map[string]interface{}, width, height int) table. // createBlockStorageTable creates a nicely formatted table for block storage volumes. func createBlockStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ - {Title: "Nom", Width: 24}, + {Title: "Name", Width: 24}, {Title: "ID", Width: 36}, - {Title: "Localisation", Width: 20}, + {Title: "Location", Width: 20}, {Title: "Type", Width: 14}, - {Title: "Capacité", Width: 10}, + {Title: "Capacity", Width: 10}, {Title: "Instance", Width: 20}, - {Title: "Chiffrement", Width: 20}, - {Title: "Statut", Width: 12}, + {Title: "Encryption", Width: 20}, + {Title: "Status", Width: 12}, } var rows []table.Row @@ -1962,9 +1962,9 @@ func createBlockStorageTable(data []map[string]interface{}, width, height int) t } } status := getString(vol, "status") - encryption := "Aucun" + encryption := "None" if strings.HasSuffix(vType, "-luks") { - encryption = "Actif" + encryption = "Active" } rows = append(rows, table.Row{name, id, region, vType, size, instance, encryption, status}) } diff --git a/internal/services/browser/backup_wizard.go b/internal/services/browser/backup_wizard.go index e7e72336..5387129c 100644 --- a/internal/services/browser/backup_wizard.go +++ b/internal/services/browser/backup_wizard.go @@ -16,8 +16,8 @@ import ( var backupTypes = []string{"Snapshot", "Backup"} var backupTypeDescriptions = []string{ - "Instantané du volume — rapide, lié au volume source", - "Sauvegarde complète indépendante — peut être restaurée même si le volume est supprimé", + "Volume snapshot — fast, linked to the source volume", + "Full independent backup — can be restored even if the volume is deleted", } // ─── Render functions ───────────────────────────────────────────────────────── @@ -47,10 +47,10 @@ func (m Model) renderBackupWizardVolumeStep(width int) string { dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("💾 Choisissez un volume à sauvegarder :") + "\n\n") + content.WriteString(titleStyle.Render("💾 Choose a volume to back up:") + "\n\n") if len(m.wizard.backupVolumes) == 0 { - content.WriteString(dimStyle.Render(" Aucun volume disponible.") + "\n") + content.WriteString(dimStyle.Render(" No volume available.") + "\n") } else { maxVisible := 12 startIdx := 0 @@ -62,7 +62,7 @@ func (m Model) renderBackupWizardVolumeStep(width int) string { endIdx = len(m.wizard.backupVolumes) } if startIdx > 0 { - content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d au-dessus)", startIdx)) + "\n") + content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d above)", startIdx)) + "\n") } for i := startIdx; i < endIdx; i++ { v := m.wizard.backupVolumes[i] @@ -80,10 +80,10 @@ func (m Model) renderBackupWizardVolumeStep(width int) string { } } if endIdx < len(m.wizard.backupVolumes) { - content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d en-dessous)", len(m.wizard.backupVolumes)-endIdx)) + "\n") + content.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d below)", len(m.wizard.backupVolumes)-endIdx)) + "\n") } } - content.WriteString("\n" + hintStyle.Render("↑↓: Naviguer • Enter: Suivant • Esc: Annuler")) + content.WriteString("\n" + hintStyle.Render("↑↓: Navigate • Enter: Next • Esc: Cancel")) return content.String() } @@ -99,8 +99,8 @@ func (m Model) renderBackupWizardTypeStep(width int) string { if m.wizard.backupVolumeIdx < len(m.wizard.backupVolumes) { volName = getStringValue(m.wizard.backupVolumes[m.wizard.backupVolumeIdx], "name", "-") } - content.WriteString(titleStyle.Render(fmt.Sprintf("Volume : %s", volName)) + "\n\n") - content.WriteString(titleStyle.Render("Choisissez un type de sauvegarde :") + "\n\n") + content.WriteString(titleStyle.Render(fmt.Sprintf("Volume: %s", volName)) + "\n\n") + content.WriteString(titleStyle.Render("Choose a backup type:") + "\n\n") for i, t := range backupTypes { if i == m.wizard.backupTypeIdx { @@ -110,7 +110,7 @@ func (m Model) renderBackupWizardTypeStep(width int) string { content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", t)) + "\n") } } - content.WriteString("\n" + hintStyle.Render("↑↓: Naviguer • Enter: Suivant • Esc: Retour")) + content.WriteString("\n" + hintStyle.Render("↑↓: Navigate • Enter: Next • Esc: Back")) return content.String() } @@ -126,13 +126,13 @@ func (m Model) renderBackupWizardNameStep(width int) string { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) backupTypeName := backupTypes[m.wizard.backupTypeIdx] - content.WriteString(titleStyle.Render(fmt.Sprintf("Nommez votre %s :", strings.ToLower(backupTypeName))) + "\n\n") + content.WriteString(titleStyle.Render(fmt.Sprintf("Name your %s:", strings.ToLower(backupTypeName))) + "\n\n") content.WriteString(inputStyle.Render(m.wizard.backupNameInput+"▌") + "\n\n") if m.wizard.errorMsg != "" { content.WriteString(errStyle.Render(" ❌ "+m.wizard.errorMsg) + "\n\n") } - content.WriteString(hintStyle.Render("Tapez un nom • Enter: Suivant • Esc: Retour")) + content.WriteString(hintStyle.Render("Type a name • Enter: Next • Esc: Back")) return content.String() } @@ -148,11 +148,11 @@ func (m Model) renderBackupWizardConfirmStep(width int) string { volRegion := getStringValue(vol, "region", "-") backupTypeName := backupTypes[m.wizard.backupTypeIdx] - content.WriteString(titleStyle.Render("Confirmer la création :") + "\n\n") - content.WriteString(labelStyle.Render(" Volume :") + valueStyle.Render(volName) + "\n") - content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(volRegion) + "\n") - content.WriteString(labelStyle.Render(" Type :") + valueStyle.Render(backupTypeName) + "\n") - content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.backupName) + "\n\n") + content.WriteString(titleStyle.Render("Confirm creation:") + "\n\n") + content.WriteString(labelStyle.Render(" Volume:") + valueStyle.Render(volName) + "\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(volRegion) + "\n") + content.WriteString(labelStyle.Render(" Type:") + valueStyle.Render(backupTypeName) + "\n") + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.backupName) + "\n\n") baseBtn := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -163,15 +163,15 @@ func (m Model) renderBackupWizardConfirmStep(width int) string { var createBtn, cancelBtn string if m.wizard.backupConfirmBtnIdx == 0 { - createBtn = baseBtn.Foreground(lipgloss.Color("#00FF7F")).BorderForeground(lipgloss.Color("#00FF7F")).Render("✓ Créer") - cancelBtn = inactiveBtn.Render("✗ Annuler") + createBtn = baseBtn.Foreground(lipgloss.Color("#00FF7F")).BorderForeground(lipgloss.Color("#00FF7F")).Render("✓ Create") + cancelBtn = inactiveBtn.Render("✗ Cancel") } else { - createBtn = inactiveBtn.Render("✓ Créer") - cancelBtn = baseBtn.Foreground(lipgloss.Color("#FF6B6B")).BorderForeground(lipgloss.Color("#FF6B6B")).Render("✗ Annuler") + createBtn = inactiveBtn.Render("✓ Create") + cancelBtn = baseBtn.Foreground(lipgloss.Color("#FF6B6B")).BorderForeground(lipgloss.Color("#FF6B6B")).Render("✗ Cancel") } content.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, createBtn, " ", cancelBtn)) content.WriteString("\n\n") - content.WriteString(hintStyle.Render("←→: Sélectionner • Enter: Confirmer • Esc: Retour")) + content.WriteString(hintStyle.Render("←→: Select • Enter: Confirm • Esc: Back")) return content.String() } @@ -238,7 +238,7 @@ func (m Model) handleBackupWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": name := strings.TrimSpace(m.wizard.backupNameInput) if name == "" { - m.wizard.errorMsg = "Le nom ne peut pas être vide." + m.wizard.errorMsg = "Name cannot be empty." return m, nil } m.wizard.errorMsg = "" @@ -281,9 +281,9 @@ func (m Model) handleBackupWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { isSnapshot := m.wizard.backupTypeIdx == 0 m.wizard.isLoading = true if isSnapshot { - m.wizard.loadingMessage = fmt.Sprintf("Création du snapshot '%s'...", name) + m.wizard.loadingMessage = fmt.Sprintf("Creating snapshot '%s'...", name) } else { - m.wizard.loadingMessage = fmt.Sprintf("Création du backup '%s'...", name) + m.wizard.loadingMessage = fmt.Sprintf("Creating backup '%s'...", name) } return m, m.createVolumeBackupOrSnapshot(volumeID, region, name, isSnapshot) case "esc": diff --git a/internal/services/browser/file_api.go b/internal/services/browser/file_api.go index 66aaacf2..33b541a9 100644 --- a/internal/services/browser/file_api.go +++ b/internal/services/browser/file_api.go @@ -202,7 +202,7 @@ func createFileStorageTable(data []map[string]interface{}, width, height int) ta {Title: "ID", Width: 20}, {Title: "Region", Width: 12}, {Title: "Type", Width: 16}, - {Title: "Capacité", Width: 12}, + {Title: "Capacity", Width: 12}, {Title: "Status", Width: 12}, } @@ -353,7 +353,7 @@ func (m Model) extendFileShare(shareId, region string, newSizeGB int) tea.Cmd { return func() tea.Msg { endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share/%s", m.cloudProject, url.PathEscape(region), url.PathEscape(shareId)) - body := map[string]interface{}{"size": newSizeGB} + body := map[string]interface{}{"newSize": newSizeGB} err := httpLib.Client.Put(endpoint, body, nil) return fileShareActionDoneMsg{action: file_storage.FileShareActionExtend, err: err} } @@ -397,11 +397,11 @@ func (m Model) handleFileShareActionDone(msg fileShareActionDoneMsg) (tea.Model, } actionNames := []string{"deleted", "renamed", "extended"} - actionName := "mis à jour" + actionName := "updated" if msg.action >= 0 && msg.action < len(actionNames) { actionName = actionNames[msg.action] } - m.notification = fmt.Sprintf("✅ Partage %s avec succès!", actionName) + m.notification = fmt.Sprintf("✅ Share %s successfully!", actionName) m.notificationExpiry = time.Now().Add(5 * time.Second) m.fileShareDetailView = nil m.detailData = nil diff --git a/internal/services/browser/file_wizard.go b/internal/services/browser/file_wizard.go index 1fc65275..25b90928 100644 --- a/internal/services/browser/file_wizard.go +++ b/internal/services/browser/file_wizard.go @@ -104,7 +104,7 @@ func (m Model) renderFileWizardSizeStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Enter file share size (GB):") + "\n\n") + content.WriteString(titleStyle.Render("Enter file share size (GB, minimum: 150, maximum: 10240 ):") + "\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) @@ -320,8 +320,12 @@ func (m Model) handleFileWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: sizeStr := strings.TrimSpace(m.wizard.fileShareSizeInput) size, err := strconv.Atoi(sizeStr) - if err != nil || size < 1 { - m.wizard.errorMsg = "Size must be a positive integer (GB)" + if err != nil || size < 150 { + m.wizard.errorMsg = "Minimum size is 150 GB" + return m, nil + } + if err != nil || size > 10240 { + m.wizard.errorMsg = "Maximum size is 10240 GB" return m, nil } m.wizard.fileShareSize = size diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 4ea92159..510884bc 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -1125,7 +1125,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) m.mode = TableView return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) @@ -1134,7 +1134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.backupType == "backup" { label = "Backup" } - m.notification = fmt.Sprintf("✅ %s '%s' créé avec succès", label, msg.name) + m.notification = fmt.Sprintf("✅ %s '%s' created successfully", label, msg.name) m.notificationExpiry = time.Now().Add(5 * time.Second) m.mode = LoadingView reloadPath := "/storage/snapshot" @@ -1157,7 +1157,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case s3SecretLoadedMsg: if msg.err != nil { - m.notification = fmt.Sprintf("❌ Impossible de récupérer la secret key: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Failed to retrieve secret key: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } @@ -1218,7 +1218,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.notification = fmt.Sprintf("🔄 Adding user access to '%s'...", containerName) m.notificationExpiry = time.Now().Add(30 * time.Second) if userID == 0 { - m.notification = fmt.Sprintf("❌ Impossible de résoudre l'ID utilisateur (type: %T, val: %v)", msg.ExtraData["userId"], msg.ExtraData["userId"]) + m.notification = fmt.Sprintf("❌ Failed to resolve user ID (type: %T, val: %v)", msg.ExtraData["userId"], msg.ExtraData["userId"]) m.notificationExpiry = time.Now().Add(10 * time.Second) return m, tea.Tick(10*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } @@ -1266,14 +1266,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case snapshotActionDoneMsg: if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } m.snapshotDetailView = nil m.mode = LoadingView if msg.action == block_storage.SnapshotActionCreateVolume { - m.notification = fmt.Sprintf("✅ Volume '%s' créé depuis le snapshot", msg.name) + m.notification = fmt.Sprintf("✅ Volume '%s' created from snapshot", msg.name) m.notificationExpiry = time.Now().Add(5 * time.Second) // Navigate to Block Storage so handleDataLoaded matches the product m.currentProduct = ProductStorageBlock @@ -1283,7 +1283,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) } - m.notification = fmt.Sprintf("✅ Snapshot '%s' supprimé", msg.name) + m.notification = fmt.Sprintf("✅ Snapshot '%s' deleted", msg.name) m.notificationExpiry = time.Now().Add(5 * time.Second) return m, tea.Batch( m.fetchDataForPath("/storage/snapshot"), @@ -1305,14 +1305,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case backupActionDoneMsg: if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur : %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } m.backupDetailView = nil m.mode = LoadingView if msg.action == block_storage.BackupActionCreateVolume { - m.notification = fmt.Sprintf("✅ Volume '%s' créé depuis le backup", msg.name) + m.notification = fmt.Sprintf("✅ Volume '%s' created from backup", msg.name) m.notificationExpiry = time.Now().Add(5 * time.Second) m.currentProduct = ProductStorageBlock m.storageSubIdx = 0 @@ -1321,9 +1321,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) } - action := fmt.Sprintf("Backup '%s' supprimé", msg.name) + action := fmt.Sprintf("Backup '%s' deleted", msg.name) if msg.action == block_storage.BackupActionRestore { - action = fmt.Sprintf("Backup '%s' restauré avec succès", msg.name) + action = fmt.Sprintf("Backup '%s' restored successfully", msg.name) } m.notification = "✅ " + action m.notificationExpiry = time.Now().Add(5 * time.Second) @@ -1358,11 +1358,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case swiftContainerUpdatedMsg: if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - m.notification = fmt.Sprintf("✅ Type du conteneur '%s' changé en %s", msg.containerName, msg.newType) + m.notification = fmt.Sprintf("✅ Container '%s' type changed to %s", msg.containerName, msg.newType) m.notificationExpiry = time.Now().Add(5 * time.Second) m.objectDetailView = nil m.detailData = nil @@ -1374,11 +1374,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case containerPolicyAddedMsg: if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur lors de l'ajout de la politique: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error adding policy: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - m.notification = fmt.Sprintf("✅ Accès '%s' ajouté sur '%s'", msg.roleName, msg.containerName) + m.notification = fmt.Sprintf("✅ Access '%s' added to '%s'", msg.roleName, msg.containerName) m.notificationExpiry = time.Now().Add(5 * time.Second) m.objectDetailView = nil m.detailData = nil @@ -1436,7 +1436,7 @@ func (m Model) handleExecuteUserAction(msg object_storage.ExecuteUserActionMsg) fmt.Sscanf(v, "%d", &userID) } if userID == 0 { - m.notification = fmt.Sprintf("❌ Impossible de résoudre l'ID utilisateur (type: %T, val: %v)", msg.User["_userId"], msg.User["_userId"]) + m.notification = fmt.Sprintf("❌ Failed to resolve user ID (type: %T, val: %v)", msg.User["_userId"], msg.User["_userId"]) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } @@ -1444,21 +1444,21 @@ func (m Model) handleExecuteUserAction(msg object_storage.ExecuteUserActionMsg) switch msg.Action { case object_storage.UserActionShowSecret: access := fmt.Sprintf("%v", msg.User["access"]) - m.notification = "🔄 Récupération de la secret key..." + m.notification = "🔄 Retrieving secret key..." m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.getS3Secret(userID, access) case object_storage.UserActionEnable: m.s3PendingEnableUser = msg.User - m.notification = "🔄 Activation de l'utilisateur..." + m.notification = "🔄 Activating user..." m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.enableS3User(userID) case object_storage.UserActionDisable: access := fmt.Sprintf("%v", msg.User["access"]) - m.notification = fmt.Sprintf("🔄 Désactivation... (userID=%d, access=%s)", userID, access) + m.notification = fmt.Sprintf("🔄 Deactivating... (userID=%d, access=%s)", userID, access) m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.disableS3User(userID, access) case object_storage.UserActionDeleteUser: - m.notification = "🗑️ Suppression de l'utilisateur..." + m.notification = "🗑️ Deleting user..." m.notificationExpiry = time.Now().Add(30 * time.Second) return m, m.deleteCloudUser(userID) } @@ -1468,7 +1468,7 @@ func (m Model) handleExecuteUserAction(msg object_storage.ExecuteUserActionMsg) // handleS3UserActionDone handles the result of enable/disable/delete user actions. func (m Model) handleS3UserActionDone(msg s3UserActionDoneMsg) (tea.Model, tea.Cmd) { if msg.err != nil { - m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } @@ -1492,7 +1492,7 @@ func (m Model) handleS3UserActionDone(msg s3UserActionDoneMsg) (tea.Model, tea.C m.mode = S3CredentialsView return m, nil case object_storage.UserActionDisable: - m.notification = "✅ Utilisateur désactivé" + m.notification = "✅ User deactivated" m.notificationExpiry = time.Now().Add(5 * time.Second) m.objectUserDetailView = nil m.mode = LoadingView @@ -1503,7 +1503,7 @@ func (m Model) handleS3UserActionDone(msg s3UserActionDoneMsg) (tea.Model, tea.C case object_storage.UserActionDeleteUser: m.objectUserDetailView = nil m.mode = LoadingView - m.notification = "✅ Utilisateur supprimé" + m.notification = "✅ User deleted" m.notificationExpiry = time.Now().Add(5 * time.Second) return m, tea.Batch( m.fetchDataForPath("/storage/object"), @@ -2662,14 +2662,14 @@ func (m Model) renderWizardView(width int) string { // Build steps based on which wizard we're in (determine by first step >= 100) if m.wizard.step >= 700 { // Backup/Snapshot wizard - steps = append(steps, "Volume", "Type", "Nom", "Confirmer") + steps = append(steps, "Volume", "Type", "Name", "Confirm") stepMapping = append(stepMapping, BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm) } else if m.wizard.step >= 600 { steps = append(steps, "Description", "Confirm") stepMapping = append(stepMapping, S3UserWizardStepDescription, S3UserWizardStepConfirm) } else if m.wizard.step >= 500 { // Object Storage wizard - steps = append(steps, "Nom", "Type", "Région", "Réplication", "Versions", "Lock", "Utilisateur", "Chiffrement", "Confirmer") + steps = append(steps, "Name", "Type", "Region", "Replication", "Versions", "Lock", "User", "Encryption", "Confirm") stepMapping = append(stepMapping, ObjectWizardStepName, ObjectWizardStepType, ObjectWizardStepRegion, ObjectWizardStepReplication, ObjectWizardStepVersioning, ObjectWizardStepObjectLock, ObjectWizardStepUser, ObjectWizardStepEncryption, ObjectWizardStepConfirm) } else if m.wizard.step >= 400 { // File Storage wizard @@ -4198,7 +4198,7 @@ func (m Model) renderVolumeWizardSizeStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Enter volume size (GB):") + "\n\n") + content.WriteString(titleStyle.Render("Enter volume size (GB, min: 10, max: 12000):") + "\n\n") if m.wizard.errorMsg != "" { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) @@ -4240,10 +4240,10 @@ func (m Model) renderVolumeWizardEncryptionStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Chiffrement") + "\n\n") + content.WriteString(titleStyle.Render("Encryption") + "\n\n") descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - content.WriteString(descStyle.Render("Activez le chiffrement pour ajouter une couche de sécurité à vos volumes\net assurer la confidentialité de vos informations.") + "\n\n") + content.WriteString(descStyle.Render("Enable encryption to add an extra layer of security to your volumes\nand ensure the confidentiality of your data.") + "\n\n") selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) @@ -4255,9 +4255,9 @@ func (m Model) renderVolumeWizardEncryptionStep(width int) string { label string disabled bool }{ - {"Aucun", false}, + {"None", false}, {"OVHcloud Managed Key", !luksSupported}, - {"Customer Managed Key (Bientôt disponible)", true}, + {"Customer Managed Key (Coming soon)", true}, } for i, opt := range options { @@ -4277,7 +4277,7 @@ func (m Model) renderVolumeWizardEncryptionStep(width int) string { } if !luksSupported { - content.WriteString("\n" + disabledStyle.Render(fmt.Sprintf(" (Le type '%s' ne supporte pas le chiffrement)", m.wizard.volumeType)) + "\n") + content.WriteString("\n" + disabledStyle.Render(fmt.Sprintf(" (Type '%s' does not support encryption)", m.wizard.volumeType)) + "\n") } content.WriteString("\n") @@ -4332,7 +4332,7 @@ func (m Model) renderVolumeWizardConfirmStep(width int) string { } content.WriteString(labelStyle.Render(" Avail. Zone:") + valueStyle.Render(azDisplay) + "\n") content.WriteString(labelStyle.Render(" Size:") + valueStyle.Render(fmt.Sprintf("%d GB", m.wizard.volumeSize)) + "\n") - encLabel := "Aucun" + encLabel := "None" if m.wizard.volumeEncryptionIdx == 1 { encLabel = "OVHcloud Managed Key (LUKS)" } @@ -7813,8 +7813,8 @@ func (m Model) handleVolumeWizardSizeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: sizeStr := strings.TrimSpace(m.wizard.volumeSizeInput) size, err := strconv.Atoi(sizeStr) - if err != nil || size < 1 { - m.wizard.errorMsg = "Size must be a positive integer (GB)" + if err != nil || size < 10 || size > 12000 { + m.wizard.errorMsg = "Size must be between 10 and 12000 GB" return m, nil } m.wizard.volumeSize = size diff --git a/internal/services/browser/object_wizard.go b/internal/services/browser/object_wizard.go index cfb2b86c..ba9df88e 100644 --- a/internal/services/browser/object_wizard.go +++ b/internal/services/browser/object_wizard.go @@ -22,8 +22,8 @@ var objectContainerTypes = []string{ } var objectContainerTypeDescriptions = []string{ - "Un large éventail de fonctionnalités compatibles avec S3.\nDisponible en 1-AZ, 3-AZ et Local Zones (Standard ou High Performance selon la région)", - "Solution basique pour le stockage sans besoin particulier en matière de performance.\nStockage objet natif d'OpenStack, avec les API Swift", + "A wide range of S3-compatible features.\nAvailable in 1-AZ, 3-AZ and Local Zones (Standard or High Performance depending on region)", + "Basic solution for storage without specific performance requirements.\nNative OpenStack object storage with Swift APIs", } func (m Model) renderObjectWizardNameStep(width int) string { @@ -61,7 +61,7 @@ func (m Model) renderObjectWizardTypeStep(width int) string { displayText := fmt.Sprintf(" ▶ %s", t) if i == 0 { badgeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) - displayText += " " + badgeStyle.Render("[Recommandée]") + displayText += " " + badgeStyle.Render("[Recommended]") } content.WriteString(selectedStyle.Render(displayText) + "\n") if i < len(objectContainerTypeDescriptions) { @@ -71,7 +71,7 @@ func (m Model) renderObjectWizardTypeStep(width int) string { } else { displayText := fmt.Sprintf(" %s", t) if i == 0 { - displayText += " [Recommandée]" + displayText += " [Recommended]" } content.WriteString(itemStyle.Render(displayText) + "\n") } @@ -90,7 +90,7 @@ func (m Model) renderObjectWizardRegionStep(width int) string { content.WriteString(titleStyle.Render("🌍 Region:") + "\n\n") if len(m.wizard.objectRegions) == 0 { - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Aucune région disponible.") + "\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("No region available.") + "\n") } else { maxVisible := 10 startIdx := 0 @@ -147,16 +147,16 @@ func (m Model) renderObjectWizardEncryptionStep(width int) string { selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - content.WriteString(titleStyle.Render("🔐 Chiffrement de vos données") + "\n\n") - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Les données déversées dans ce conteneur sont chiffrées à la volée par OVHcloud.") + "\n\n") + content.WriteString(titleStyle.Render("🔐 Data encryption") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Data stored in this container is encrypted on the fly by OVHcloud.") + "\n\n") options := []struct { label string desc string value bool }{ - {"Pas de chiffrement", "", false}, - {"Chiffrement côté serveur avec des clés gérées par OVHcloud (SSE-OMK)", "", true}, + {"No encryption", "", false}, + {"Server-side encryption with OVHcloud-managed keys (SSE-OMK)", "", true}, } for _, opt := range options { @@ -171,7 +171,7 @@ func (m Model) renderObjectWizardEncryptionStep(width int) string { } } content.WriteString("\n") - content.WriteString(hintStyle.Render("↑↓: Sélectionner • Enter: Suivant • Esc: Retour")) + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Next • Esc: Back")) return content.String() } @@ -214,9 +214,9 @@ func (m Model) renderObjectWizardUserStep(width int) string { // First option: no user (project-level) if m.wizard.objectUserIdx == 0 { - content.WriteString(selectedStyle.Render(" ▶ [Aucun utilisateur spécifique]") + "\n") + content.WriteString(selectedStyle.Render(" ▶ [No specific user]") + "\n") } else { - content.WriteString(itemStyle.Render(" [Aucun utilisateur spécifique]") + "\n") + content.WriteString(itemStyle.Render(" [No specific user]") + "\n") } for i, user := range m.wizard.objectUsers { @@ -237,7 +237,7 @@ func (m Model) renderObjectWizardUserStep(width int) string { } if len(m.wizard.objectUsers) == 0 { - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" (aucun utilisateur trouvé)") + "\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" (no user found)") + "\n") } content.WriteString("\n") @@ -271,11 +271,11 @@ func (m Model) renderObjectWizardConfirmStep(width int) string { content.WriteString(labelStyle.Render(" Replication: ") + valueStyle.Render(boolToEnglish(m.wizard.objectReplication)) + "\n") content.WriteString(labelStyle.Render(" Versioning: ") + valueStyle.Render(boolToEnglish(m.wizard.objectVersioning)) + "\n") content.WriteString(labelStyle.Render(" Object Lock: ") + valueStyle.Render(boolToEnglish(m.wizard.objectLock)) + "\n") - encryptionLabel := "Pas de chiffrement" + encryptionLabel := "No encryption" if m.wizard.objectEncryption { - encryptionLabel = "SSE-OMK (clés OVHcloud)" + encryptionLabel = "SSE-OMK (OVHcloud keys)" } - content.WriteString(labelStyle.Render(" Chiffrement: ") + valueStyle.Render(encryptionLabel) + "\n") + content.WriteString(labelStyle.Render(" Encryption: ") + valueStyle.Render(encryptionLabel) + "\n") if m.wizard.objectUserIdx > 0 && m.wizard.objectUserIdx <= len(m.wizard.objectUsers) { user := m.wizard.objectUsers[m.wizard.objectUserIdx-1] @@ -332,7 +332,7 @@ func (m Model) handleObjectWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Enforce S3 bucket naming rules if len(name) < 3 || len(name) > 63 { - m.wizard.errorMsg = "Le nom doit contenir entre 3 et 63 caractères." + m.wizard.errorMsg = "Name must be between 3 and 63 characters." return m, nil } for _, ch := range name { @@ -564,7 +564,7 @@ func (m Model) renderS3CredentialsView(width int) string { dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) if m.s3CredentialsFromEnable { - content.WriteString(titleStyle.Render("✅ Utilisateur activé avec succès !") + "\n\n") + content.WriteString(titleStyle.Render("✅ User activated successfully!") + "\n\n") } else { content.WriteString(titleStyle.Render("✅ S3 User created successfully!") + "\n\n") } @@ -656,9 +656,9 @@ var swiftContainerTypes = []string{ } var swiftContainerTypeDescriptions = []string{ - "Hébergement statique - Accès rapide et performant pour vos sites. Liez vos domaines et déposez vos fichiers", - "Privé - Facturation, informations légales, logs. Archivez simplement et selon vos usages", - "Public - Multimédia, binaires, e-commerce. Stockez une infinité de données", + "Static hosting - Fast and efficient access for your sites. Link your domains and upload your files", + "Private - Billing, legal information, logs. Archive simply according to your needs", + "Public - Multimedia, binaries, e-commerce. Store an unlimited amount of data", } func (m Model) renderObjectWizardSwiftTypeStep(width int) string { @@ -692,10 +692,10 @@ func (m Model) renderObjectWizardSwiftRegionStep(width int) string { itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - content.WriteString(titleStyle.Render("🌍 Région:") + "\n\n") + content.WriteString(titleStyle.Render("🌍 Region:") + "\n\n") if len(m.wizard.objectSwiftRegions) == 0 { - content.WriteString(itemStyle.Render(" (aucune région disponible)") + "\n\n") + content.WriteString(itemStyle.Render(" (no region available)") + "\n\n") } else { maxVisible := 10 startIdx := 0 diff --git a/internal/services/browser/views/block_storage/backup_detail.go b/internal/services/browser/views/block_storage/backup_detail.go index 733d2126..5cdad0e9 100644 --- a/internal/services/browser/views/block_storage/backup_detail.go +++ b/internal/services/browser/views/block_storage/backup_detail.go @@ -107,28 +107,28 @@ func (v *BackupDetailView) renderActions() string { Width(40) if v.subMenu == backupSubName { - return views.StyleStatusWarning.Render("Nom du nouveau volume :") + "\n" + + return views.StyleStatusWarning.Render("New volume name:") + "\n" + inputStyle.Render(v.nameInput+"▌") + "\n\n" + - views.StyleFooter.Render("Enter: Suivant • Esc: Annuler") + views.StyleFooter.Render("Enter: Next • Esc: Cancel") } if v.subMenu == backupSubSize { currentSize := getSizeStr(v.backup) - return views.StyleStatusWarning.Render(fmt.Sprintf("Taille en GB (backup: %s GB, doit être ≥) :", currentSize)) + "\n" + + return views.StyleStatusWarning.Render(fmt.Sprintf("Size in GB (backup: %s GB, must be ≥):", currentSize)) + "\n" + inputStyle.Render(v.sizeInput+"▌") + "\n\n" + - views.StyleFooter.Render("Enter: Créer • Esc: Retour") + views.StyleFooter.Render("Enter: Create • Esc: Back") } if v.subMenu == backupSubPicker { if len(v.restoreVolumes) == 0 { - return views.StyleStatusWarning.Render("⏳ Chargement des volumes...") + "\n\n" + - views.StyleFooter.Render("Esc: Annuler") + return views.StyleStatusWarning.Render("⏳ Loading volumes...") + "\n\n" + + views.StyleFooter.Render("Esc: Cancel") } selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true) itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) var sb strings.Builder - sb.WriteString(views.StyleStatusWarning.Render("⚠️ Choisissez le volume cible (les données seront écrasées) :") + "\n\n") + sb.WriteString(views.StyleStatusWarning.Render("⚠️ Choose the target volume (data will be overwritten):") + "\n\n") maxVisible := 8 startIdx := 0 if v.restoreIdx >= maxVisible { @@ -139,7 +139,7 @@ func (v *BackupDetailView) renderActions() string { endIdx = len(v.restoreVolumes) } if startIdx > 0 { - sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d au-dessus)", startIdx)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d above)", startIdx)) + "\n") } for i := startIdx; i < endIdx; i++ { vol := v.restoreVolumes[i] @@ -151,9 +151,9 @@ func (v *BackupDetailView) renderActions() string { } } if endIdx < len(v.restoreVolumes) { - sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d en-dessous)", len(v.restoreVolumes)-endIdx)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf(" (...%d below)", len(v.restoreVolumes)-endIdx)) + "\n") } - sb.WriteString("\n" + views.StyleFooter.Render("↑↓: Navigate • Enter: Confirm Restore • Esc: Annuler")) + sb.WriteString("\n" + views.StyleFooter.Render("↑↓: Navigate • Enter: Confirm Restore • Esc: Cancel")) return sb.String() } @@ -332,9 +332,9 @@ func (v *BackupDetailView) HelpText() string { case backupSubPicker: return "↑↓: Navigate • Enter: Restore • Esc: Cancel" case backupSubName: - return "Type name • Enter: Suivant • Esc: Cancel" + return "Type name • Enter: Next • Esc: Cancel" case backupSubSize: - return "Type size in GB • Enter: Créer • Esc: Retour" + return "Type size in GB • Enter: Create • Esc: Back" } if v.confirmMode { return "Enter: Confirm Delete • Esc: Cancel" diff --git a/internal/services/browser/views/block_storage/detail.go b/internal/services/browser/views/block_storage/detail.go index 2bcf7323..a2c869a5 100644 --- a/internal/services/browser/views/block_storage/detail.go +++ b/internal/services/browser/views/block_storage/detail.go @@ -61,10 +61,10 @@ func (v *DetailView) Render(width, height int) string { size := getSizeStr(v.volume) bootable := getBootable(v.volume) - encryptionLabel := "Aucun" + encryptionLabel := "None" encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) if strings.HasSuffix(vType, "-luks") { - encryptionLabel = "Actif" + encryptionLabel = "Active" encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) } diff --git a/internal/services/browser/views/block_storage/snapshot_detail.go b/internal/services/browser/views/block_storage/snapshot_detail.go index 7c2fb7c5..48f70e36 100644 --- a/internal/services/browser/views/block_storage/snapshot_detail.go +++ b/internal/services/browser/views/block_storage/snapshot_detail.go @@ -92,13 +92,13 @@ func (v *SnapshotDetailView) renderActions(currentSize string) string { switch v.subMenu { case snapshotSubName: - return views.StyleStatusWarning.Render("Nom du nouveau volume :") + "\n" + + return views.StyleStatusWarning.Render("New volume name:") + "\n" + inputStyle.Render(v.nameInput+"▌") + "\n\n" + - views.StyleFooter.Render("Enter: Suivant • Esc: Annuler") + views.StyleFooter.Render("Enter: Next • Esc: Cancel") case snapshotSubSize: - return views.StyleStatusWarning.Render(fmt.Sprintf("Taille en GB (actuelle: %s GB, doit être ≥) :", currentSize)) + "\n" + + return views.StyleStatusWarning.Render(fmt.Sprintf("Size in GB (current: %s GB, must be ≥):", currentSize)) + "\n" + inputStyle.Render(v.sizeInput+"▌") + "\n\n" + - views.StyleFooter.Render("Enter: Créer • Esc: Retour") + views.StyleFooter.Render("Enter: Create • Esc: Back") } var parts []string diff --git a/internal/services/browser/views/file_storage/detail.go b/internal/services/browser/views/file_storage/detail.go index b3be808b..f9d074ce 100644 --- a/internal/services/browser/views/file_storage/detail.go +++ b/internal/services/browser/views/file_storage/detail.go @@ -93,7 +93,7 @@ func (v *DetailView) renderActions() string { BorderForeground(lipgloss.Color("#00FF7F")). Padding(0, 1). Width(40) - return views.StyleStatusWarning.Render("Nouveau nom :") + "\n" + + return views.StyleStatusWarning.Render("New name:") + "\n" + inputStyle.Render(v.renameInput+"▌") + "\n\n" + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") } @@ -106,7 +106,7 @@ func (v *DetailView) renderActions() string { Padding(0, 1). Width(20) return views.StyleStatusWarning.Render( - fmt.Sprintf("New size in GB (current: %s GB, must be larger):", currentSize), + fmt.Sprintf("New size in GB (current: %s GB, minimum: %s GB, must be larger):", currentSize, currentSize), ) + "\n" + inputStyle.Render(v.extendInput+"▌") + "\n\n" + views.StyleFooter.Render("Enter: Confirm • Esc: Cancel") @@ -180,7 +180,7 @@ func (v *DetailView) HandleKey(msg tea.KeyMsg) tea.Cmd { var newSizeInt, currentSizeInt int fmt.Sscanf(newSize, "%d", &newSizeInt) fmt.Sscanf(currentSizeStr, "%d", ¤tSizeInt) - if newSizeInt <= currentSizeInt { + if newSizeInt < 150 || newSizeInt <= currentSizeInt { v.extendInput = "" return nil } diff --git a/internal/services/browser/views/object_storage/detail.go b/internal/services/browser/views/object_storage/detail.go index 9acb890e..57b520b0 100644 --- a/internal/services/browser/views/object_storage/detail.go +++ b/internal/services/browser/views/object_storage/detail.go @@ -131,11 +131,11 @@ func (v *DetailView) renderInfo() string { } // Encryption - encryptionStatus := "Pas de chiffrement" + encryptionStatus := "No encryption" encryptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) if enc, ok := v.container["encryption"].(map[string]interface{}); ok { if alg, _ := enc["sseAlgorithm"].(string); alg != "" { - encryptionStatus = "SSE-OMK (clés gérées par OVHcloud)" + encryptionStatus = "SSE-OMK (OVHcloud-managed keys)" encryptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) } } @@ -207,7 +207,7 @@ func (v *DetailView) renderChangeTypeMenu(width int) string { current := getString(v.container, "containerType") if current != "" { descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - content.WriteString(descStyle.Render(fmt.Sprintf("Type actuel : %s", current)) + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Current type: %s", current)) + "\n\n") } for i, opt := range swiftTypeOptions { if i == v.subMenuIdx { @@ -217,8 +217,8 @@ func (v *DetailView) renderChangeTypeMenu(width int) string { } } content.WriteString("\n") - content.WriteString(hintStyle.Render("↑↓: Sélectionner • Enter: Confirmer • Esc: Annuler")) - return views.RenderBox("Modifier le type du conteneur", content.String(), width-4) + content.WriteString(hintStyle.Render("↑↓: Select • Enter: Confirm • Esc: Cancel")) + return views.RenderBox("Change container type", content.String(), width-4) } func (v *DetailView) policyUserCandidates() []map[string]interface{} { @@ -247,11 +247,11 @@ func (v *DetailView) renderPickUserMenu(width int) string { candidates := v.policyUserCandidates() if len(candidates) == 0 { - content.WriteString(dimStyle.Render("Aucun utilisateur cloud disponible. Créez d'abord un utilisateur S3.") + "\n") - content.WriteString("\n" + hintStyle.Render("Esc: Annuler")) - return views.RenderBox("Ajouter un accès utilisateur", content.String(), width-4) + content.WriteString(dimStyle.Render("No cloud user available. Please create an S3 user first.") + "\n") + content.WriteString("\n" + hintStyle.Render("Esc: Cancel")) + return views.RenderBox("Add user access", content.String(), width-4) } - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Sélectionnez l'utilisateur à autoriser :") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Select the user to grant access:") + "\n\n") for i, u := range candidates { name := fmt.Sprintf("%v", u["_username"]) if name == "" || name == "" { @@ -263,8 +263,8 @@ func (v *DetailView) renderPickUserMenu(width int) string { content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", name)) + "\n") } } - content.WriteString("\n" + hintStyle.Render("↑↓: Sélectionner • Enter: Suivant • Esc: Annuler")) - return views.RenderBox("Ajouter un accès utilisateur (1/2 : Utilisateur)", content.String(), width-4) + content.WriteString("\n" + hintStyle.Render("↑↓: Select • Enter: Next • Esc: Cancel")) + return views.RenderBox("Add user access (1/2: User)", content.String(), width-4) } func (v *DetailView) renderPickRoleMenu(width int) string { @@ -274,12 +274,12 @@ func (v *DetailView) renderPickRoleMenu(width int) string { hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) roleDescriptions := map[string]string{ - "readWrite": "Lecture + écriture (recommandé)", - "readOnly": "Lecture seule", - "admin": "Accès complet (admin)", - "deny": "Refuser tout accès", + "readWrite": "Read + write (recommended)", + "readOnly": "Read only", + "admin": "Full access (admin)", + "deny": "Deny all access", } - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Sélectionnez le rôle :") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render("Select the role:") + "\n\n") for i, role := range policyRoles { desc := roleDescriptions[role] if i == v.subMenuIdx { @@ -288,8 +288,8 @@ func (v *DetailView) renderPickRoleMenu(width int) string { content.WriteString(itemStyle.Render(fmt.Sprintf(" %-12s %s", role, desc)) + "\n") } } - content.WriteString("\n" + hintStyle.Render("↑↓: Sélectionner • Enter: Confirmer • Esc: Retour")) - return views.RenderBox("Ajouter un accès utilisateur (2/2 : Rôle)", content.String(), width-4) + content.WriteString("\n" + hintStyle.Render("↑↓: Select • Enter: Confirm • Esc: Back")) + return views.RenderBox("Add user access (2/2: Role)", content.String(), width-4) } // ─── Key handling ───────────────────────────────────────────────────────────── @@ -443,14 +443,14 @@ func (v *DetailView) Title() string { func (v *DetailView) HelpText() string { switch v.subMenu { case subMenuChangeType: - return "↑↓: Sélectionner • Enter: Confirmer • Esc: Annuler" + return "↑↓: Select • Enter: Confirm • Esc: Cancel" case subMenuAddPolicy: - return "↑↓: Sélectionner l'utilisateur • Enter: Suivant • Esc: Annuler" + return "↑↓: Select user • Enter: Next • Esc: Cancel" case subMenuPickRole: - return "↑↓: Sélectionner le rôle • Enter: Confirmer • Esc: Retour" + return "↑↓: Select role • Enter: Confirm • Esc: Back" } if v.confirmMode { - return "Enter: Confirmer la suppression • Esc: Annuler" + return "Enter: Confirm deletion • Esc: Cancel" } return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" } diff --git a/internal/services/browser/views/object_storage/user_detail.go b/internal/services/browser/views/object_storage/user_detail.go index e6c16a73..bfea46a2 100644 --- a/internal/services/browser/views/object_storage/user_detail.go +++ b/internal/services/browser/views/object_storage/user_detail.go @@ -115,7 +115,7 @@ func (v *UserDetailView) renderSecretBox(width int) string { secretStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) content.WriteString(secretStyle.Render(v.secretKey) + "\n\n") - content.WriteString(hintStyle.Render("⚠️ Notez cette clé, elle ne sera plus affichée.")) + content.WriteString(hintStyle.Render("⚠️ Note this key, it will not be shown again.")) return views.RenderBox("Secret Key", content.String(), width-4) } @@ -220,10 +220,10 @@ func (v *UserDetailView) Title() string { func (v *UserDetailView) HelpText() string { if v.confirmMode { - return "Enter: Confirmer • Esc: Annuler" + return "Enter: Confirm • Esc: Cancel" } if v.showSecret { - return "Esc: Fermer la secret key" + return "Esc: Close secret key" } return "←→: Select • Enter: Execute • Esc: Back to list • q: Quit" } From e72925f5a6cf15b13153f277ff2bae1fc2136208 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 08:45:39 +0000 Subject: [PATCH 26/55] feat(browser): added under navigate navbar Signed-off-by: olivier dubo --- internal/services/browser/api.go | 41 +++++++ internal/services/browser/manager.go | 164 ++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 5 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 7ebb0f48..830a2aa4 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -117,6 +117,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/networks/gateway": + return func() tea.Msg { + msg := m.fetchGatewaysData() + msg.forProduct = product + return msg + } case "/storage/snapshot": return func() tea.Msg { msg := m.fetchVolumeSnapshotsData() @@ -1364,6 +1370,41 @@ func (m Model) fetchLoadBalancersData() dataLoadedMsg { } } +// fetchGatewaysData fetches gateways across all regions +func (m Model) fetchGatewaysData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{ + err: fmt.Errorf("no cloud project selected"), + } + } + + var regionNames []string + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(regionEndpoint, ®ionNames); err != nil { + return dataLoadedMsg{err: err} + } + + regions := make([]any, len(regionNames)) + for i, r := range regionNames { + regions[i] = r + } + + allRegionGateways, err := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/gateway", regions, true) + if err != nil { + return dataLoadedMsg{err: err} + } + + var gateways []map[string]interface{} + for _, regionGateways := range allRegionGateways { + gateways = append(gateways, regionGateways...) + } + + return dataLoadedMsg{ + data: gateways, + err: nil, + } +} + // handleProjectsLoaded processes the loaded projects data func (m Model) handleProjectsLoaded(msg projectsLoadedMsg) (tea.Model, tea.Cmd) { // Ignore stale response if user switched to a different product diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 510884bc..88101423 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -155,6 +155,10 @@ const ( ProductStorageObject // Object Storage (sous-nav) ProductStorageArchive // Cloud Archive (sous-nav) ProductNetworks + ProductNetworkPrivate // Private Networks (sub-nav) + ProductNetworkPublic // Public IPs (sub-nav) + ProductNetworkGateway // Gateways (sub-nav) + ProductNetworkLB // Load Balancers (sub-nav) ProductProjects ) @@ -354,6 +358,8 @@ type Model struct { navIdx int // Index in navigation bar storageSubIdx int // Index in storage sub-navigation (0=Prise en main, 1=Block Storage, ...) inStorageSubNav bool // Whether the keyboard focus is in the storage sub-nav bar + networkSubIdx int // Index in network sub-navigation + inNetworkSubNav bool // Whether the keyboard focus is in the network sub-nav bar table table.Model detailData map[string]interface{} currentData []map[string]interface{} @@ -780,7 +786,7 @@ func getNavItems() []NavItem { {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, {Label: "Storage", Icon: "💾", Product: ProductStorage, Path: "/storage/block"}, - {Label: "Private networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, + {Label: "Networks", Icon: "🌐", Product: ProductNetworks, Path: "/networks/private"}, } } @@ -801,6 +807,22 @@ func getStorageSubItems() []StorageSubItem { } } +type NetworkSubItem struct { + Label string + Product ProductType + Path string + Enabled bool +} + +func getNetworkSubItems() []NetworkSubItem { + return []NetworkSubItem{ + {Label: "Private Networks", Product: ProductNetworkPrivate, Path: "/networks/private", Enabled: true}, + {Label: "Public IPs", Product: ProductNetworkPublic, Path: "/networks/public", Enabled: true}, + {Label: "Gateways", Product: ProductNetworkGateway, Path: "/networks/gateway", Enabled: true}, + {Label: "Load Balancers", Product: ProductNetworkLB, Path: "/loadbalancer", Enabled: true}, + } +} + // StartBrowser is the entry point for the browser TUI func StartBrowser(cmd *cobra.Command, args []string) { // Reset creation command @@ -1655,6 +1677,14 @@ func (m Model) renderNavBar(width int) string { return mainNav + "\n" + subNav } + // Show network sub-navigation when on Networks or any network sub-product + isNetworkContext := navItems[m.navIdx].Product == ProductNetworks || + (m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB) + if isNetworkContext { + subNav := m.renderNetworkSubNav(width) + return mainNav + "\n" + subNav + } + return mainNav } @@ -1713,6 +1743,52 @@ func (m Model) renderStorageSubNav(width int) string { return subBarStyle.Width(width - 2).Render(subContent) } +func (m Model) renderNetworkSubNav(width int) string { + subItems := getNetworkSubItems() + var items []string + + subItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Padding(0, 2) + subItemDisabledStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444444")). + Padding(0, 2) + + activeSubIdx := m.networkSubIdx + for i, item := range subItems { + if item.Product == m.currentProduct { + activeSubIdx = i + break + } + } + + for i, item := range subItems { + var style lipgloss.Style + if i == activeSubIdx && m.inNetworkSubNav { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) + } else if i == activeSubIdx { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) + } else if !item.Enabled { + style = subItemDisabledStyle + } else { + style = subItemStyle + } + items = append(items, style.Render(item.Label)) + } + + borderColor := lipgloss.Color("#333333") + if m.inNetworkSubNav { + borderColor = lipgloss.Color("#00FF7F") + } + subBarStyle := lipgloss.NewStyle(). + Padding(0, 1). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(borderColor) + subContent := lipgloss.JoinHorizontal(lipgloss.Top, items...) + return subBarStyle.Width(width - 2).Render(subContent) +} + func (m Model) renderContentBox(width int) string { var titleText string @@ -4423,8 +4499,14 @@ func (m Model) getProductCreationInfo() (string, string) { return "file shares", "" case ProductStorageObject: return "object storage containers", "" - case ProductNetworks: + case ProductNetworks, ProductNetworkPrivate: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) + case ProductNetworkPublic: + return "public IPs", "" + case ProductNetworkGateway: + return "gateways", "" + case ProductNetworkLB: + return "load balancers", "" default: return "resources", "" } @@ -5219,10 +5301,23 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) return m.loadStorageSubProduct() } + // In network sub-nav (only when focused) + if m.inNetworkSubNav && m.mode != DetailView { + subItems := getNetworkSubItems() + for i, item := range subItems { + if item.Product == m.currentProduct { + m.networkSubIdx = i + break + } + } + m.networkSubIdx = (m.networkSubIdx - 1 + len(subItems)) % len(subItems) + return m.loadNetworkSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { if m.navIdx > 0 { m.navIdx-- m.inStorageSubNav = false + m.inNetworkSubNav = false return m.loadCurrentProduct() } } @@ -5266,11 +5361,25 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) return m.loadStorageSubProduct() } + // In network sub-nav (only when focused) + isNetworkSubProduct2 := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB + if m.inNetworkSubNav && isNetworkSubProduct2 && m.mode != DetailView { + subItems := getNetworkSubItems() + for i, item := range subItems { + if item.Product == m.currentProduct { + m.networkSubIdx = i + break + } + } + m.networkSubIdx = (m.networkSubIdx + 1) % len(subItems) + return m.loadNetworkSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { navItems := getNavItems() if m.navIdx < len(navItems)-1 { m.navIdx++ m.inStorageSubNav = false + m.inNetworkSubNav = false return m.loadCurrentProduct() } } @@ -5388,6 +5497,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inStorageSubNav = true return m.loadStorageSubProduct() } + if m.navIdx < len(navItems) && navItems[m.navIdx].Product == ProductNetworks { + if m.inNetworkSubNav { + // Go back to main nav + m.inNetworkSubNav = false + return m, nil + } + // Drop into sub-nav + m.inNetworkSubNav = true + return m.loadNetworkSubProduct() + } } // Handle enter based on current mode if m.mode == NodePoolDetailView { @@ -5527,6 +5646,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() navItems := getNavItems() isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive + isNetworkSubProduct := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB if (key == "down" || key == "j") && !m.inStorageSubNav && m.mode != DetailView && m.mode != ProjectSelectView && !isStorageSubProduct && navItems[m.navIdx].Product == ProductStorage { m.inStorageSubNav = true @@ -5543,6 +5663,23 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inStorageSubNav = true return m, nil } + // Down into network sub-nav + if (key == "down" || key == "j") && !m.inNetworkSubNav && m.mode != DetailView && + m.mode != ProjectSelectView && !isNetworkSubProduct && navItems[m.navIdx].Product == ProductNetworks { + m.inNetworkSubNav = true + return m.loadNetworkSubProduct() + } + if (key == "up" || key == "k") && m.inNetworkSubNav && m.mode != DetailView { + if m.mode != TableView || m.table.Cursor() == 0 { + m.inNetworkSubNav = false + return m, nil + } + } + // ↑ on EmptyView/ComingSoonView for network → focus sub-nav + if (key == "up" || key == "k") && isNetworkSubProduct && !m.inNetworkSubNav && (m.mode == EmptyView || m.mode == ComingSoonView) { + m.inNetworkSubNav = true + return m, nil + } // Node pools list navigation if m.mode == NodePoolsView { clusterId := getStringValue(m.detailData, "id", "") @@ -7406,11 +7543,12 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { m.detailData = nil m.currentData = nil m.inStorageSubNav = false + m.inNetworkSubNav = false - // Show coming soon view for unimplemented products + // For Networks, go to default sub-item (Private Networks = index 0) if currentNav.Product == ProductNetworks { - m.mode = ComingSoonView - return m, nil + m.networkSubIdx = 0 + return m.loadNetworkSubProduct() } // For Stockage, go to default sub-item (Block Storage = index 0) @@ -7448,6 +7586,22 @@ func (m Model) loadStorageSubProduct() (Model, tea.Cmd) { return m, m.fetchDataForPath(sub.Path) } +func (m Model) loadNetworkSubProduct() (Model, tea.Cmd) { + subItems := getNetworkSubItems() + sub := subItems[m.networkSubIdx] + m.currentProduct = sub.Product + m.detailData = nil + m.currentData = nil + + if !sub.Enabled { + m.mode = ComingSoonView + return m, nil + } + + m.mode = LoadingView + return m, m.fetchDataForPath(sub.Path) +} + // Helper functions func getStringValue(data map[string]interface{}, key string, defaultVal string) string { if val, ok := data[key]; ok { From 138f51a4ec02fb563ceb54e99c1cf3db2f4fe44f Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 09:06:12 +0000 Subject: [PATCH 27/55] feat(browser): fixed visual of private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 183 +++++++++++++++++++-------- internal/services/browser/manager.go | 2 + 2 files changed, 133 insertions(+), 52 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 830a2aa4..a9295004 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1268,7 +1268,7 @@ func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.C ) } -// fetchPrivateNetworksData fetches private networks across all regions +// fetchPrivateNetworksData fetches private networks and enriches each with subnet details func (m Model) fetchPrivateNetworksData() dataLoadedMsg { if m.cloudProject == "" { return dataLoadedMsg{ @@ -1276,31 +1276,22 @@ func (m Model) fetchPrivateNetworksData() dataLoadedMsg { } } - // Fetch all regions - var regionNames []string - regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(regionEndpoint, ®ionNames); err != nil { + var networks []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) + if err := httpLib.Client.Get(endpoint, &networks); err != nil { return dataLoadedMsg{err: err} } - regions := make([]any, len(regionNames)) - for i, r := range regionNames { - regions[i] = r - } - - // Fetch networks in all regions - allRegionNetworks, err := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/network", regions, true) - if err != nil { - return dataLoadedMsg{err: err} + // Enrich with subnet data in parallel + networkIDs := make([]any, len(networks)) + for i, n := range networks { + networkIDs[i] = getString(n, "id") } - - // Flatten and filter private networks - var networks []map[string]interface{} - for _, regionNetworks := range allRegionNetworks { - for _, network := range regionNetworks { - if v, ok := network["visibility"]; ok && v == "private" { - networks = append(networks, network) - } + subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%%s/subnet", m.cloudProject) + allSubnets, _ := httpLib.FetchObjectsParallel[[]map[string]any](subnetEndpoint, networkIDs, true) + for i, subnets := range allSubnets { + if i < len(networks) { + networks[i]["_subnets"] = subnets } } @@ -1310,7 +1301,7 @@ func (m Model) fetchPrivateNetworksData() dataLoadedMsg { } } -// fetchPublicNetworksData fetches public networks across all regions +// fetchPublicNetworksData fetches public networks at project level func (m Model) fetchPublicNetworksData() dataLoadedMsg { if m.cloudProject == "" { return dataLoadedMsg{ @@ -1318,41 +1309,17 @@ func (m Model) fetchPublicNetworksData() dataLoadedMsg { } } - // Fetch all regions - var regionNames []string - regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(regionEndpoint, ®ionNames); err != nil { - return dataLoadedMsg{err: err} - } - - regions := make([]any, len(regionNames)) - for i, r := range regionNames { - regions[i] = r - } - - // Fetch networks in all regions - allRegionNetworks, err := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/network", regions, true) - if err != nil { - return dataLoadedMsg{err: err} - } - - // Flatten and filter public networks var networks []map[string]interface{} - for _, regionNetworks := range allRegionNetworks { - for _, network := range regionNetworks { - if v, ok := network["visibility"]; ok && v == "public" { - networks = append(networks, network) - } - } - } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/public", m.cloudProject) + err := httpLib.Client.Get(endpoint, &networks) return dataLoadedMsg{ data: networks, - err: nil, + err: err, } } -// fetchLoadBalancersData fetches load balancers +// fetchLoadBalancersData fetches load balancers at project level func (m Model) fetchLoadBalancersData() dataLoadedMsg { if m.cloudProject == "" { return dataLoadedMsg{ @@ -1361,7 +1328,7 @@ func (m Model) fetchLoadBalancersData() dataLoadedMsg { } var loadbalancers []map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/networkloadbalancer", m.cloudProject) err := httpLib.Client.Get(endpoint, &loadbalancers) return dataLoadedMsg{ @@ -1603,6 +1570,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createVolumeSnapshotsTable(msg.data, m.width, m.height) case ProductStorageBackup: m.table = createVolumeBackupsTable(msg.data, m.width, m.height) + case ProductNetworkPrivate: + m.table = createPrivateNetworksTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -1962,6 +1931,116 @@ func createGenericTable(data []map[string]interface{}, width, height int) table. return t } +// createPrivateNetworksTable creates a table for private networks. +func createPrivateNetworksTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "VLAN ID", Width: 8}, + {Title: "Name", Width: 22}, + {Title: "Location", Width: 20}, + {Title: "CIDR", Width: 20}, + {Title: "Gateway", Width: 16}, + {Title: "DHCP", Width: 6}, + {Title: "IP Allocated", Width: 33}, + } + + var rows []table.Row + for _, net := range data { + vlanId := "-" + if v, ok := net["vlanId"]; ok { + switch n := v.(type) { + case float64: + vlanId = fmt.Sprintf("%d", int(n)) + case json.Number: + if i, err := n.Int64(); err == nil { + vlanId = fmt.Sprintf("%d", i) + } + } + } + + name := getString(net, "name") + + // Collect regions + var locationParts []string + if regions, ok := net["regions"].([]interface{}); ok { + for _, r := range regions { + if rm, ok := r.(map[string]interface{}); ok { + if reg := getString(rm, "region"); reg != "" { + locationParts = append(locationParts, reg) + } + } + } + } + location := strings.Join(locationParts, ", ") + if location == "" { + location = "-" + } + + // Extract subnet data (first subnet) + cidr := "-" + gateway := "-" + dhcp := "-" + ipAllocated := "-" + + if subnets, ok := net["_subnets"].([]map[string]interface{}); ok && len(subnets) > 0 { + sub := subnets[0] + if v := getString(sub, "cidr"); v != "" { + cidr = v + } + if v := getString(sub, "gatewayIp"); v != "" { + gateway = v + } + if v, ok := sub["dhcpEnabled"].(bool); ok { + if v { + dhcp = "Active" + } else { + dhcp = "Inactive" + } + } + // Show IP range from first ipPool + if pools, ok := sub["ipPools"].([]interface{}); ok && len(pools) > 0 { + if pm, ok := pools[0].(map[string]interface{}); ok { + start := getString(pm, "start") + end := getString(pm, "end") + if start != "" && end != "" { + ipAllocated = start + " - " + end + } + } + } + } + + rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, ipAllocated}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + // createBlockStorageTable creates a nicely formatted table for block storage volumes. func createBlockStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 88101423..b4b8f150 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -5955,6 +5955,8 @@ func (m *Model) applyTableFilter() { m.table = createFileStorageTable(m.currentData, m.width, m.height) case ProductStorageObject: m.table = createObjectStorageTable(m.currentData, m.width, m.height) + case ProductNetworkPrivate: + m.table = createPrivateNetworksTable(m.currentData, m.width, m.height) default: m.table = createGenericTable(m.currentData, m.width, m.height) } From f5b9d375ee436a367d8a281bb087f7c4739c7178 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 09:28:03 +0000 Subject: [PATCH 28/55] feat(browser): fixed visual of public Ips Signed-off-by: olivier dubo --- internal/services/browser/api.go | 143 +++++++++++++++++++++++++++ internal/services/browser/manager.go | 2 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index a9295004..05cc2c6f 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -111,6 +111,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/networks/floatingip": + return func() tea.Msg { + msg := m.fetchFloatingIPsData() + msg.forProduct = product + return msg + } case "/loadbalancer": return func() tea.Msg { msg := m.fetchLoadBalancersData() @@ -1319,6 +1325,75 @@ func (m Model) fetchPublicNetworksData() dataLoadedMsg { } } +// fetchFloatingIPsData fetches floating IPs from regions that support the "network" feature +func (m Model) fetchFloatingIPsData() dataLoadedMsg { + os.WriteFile("/tmp/floatingip_called.txt", []byte("called project="+m.cloudProject), 0644) + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + + // Fetch full region details (with services) to filter by "network" feature + regionDetails, err := httpLib.FetchExpandedArray(regionEndpoint, "") + if err != nil { + return dataLoadedMsg{err: err} + } + + // Debug: dump region details to understand structure + if b, err2 := json.MarshalIndent(regionDetails, "", " "); err2 == nil { + os.WriteFile("/tmp/floatingip_regions_debug.json", b, 0644) + } + + var regions []any + var regionNames []string + for _, r := range regionDetails { + name, _ := r["name"].(string) + if name == "" { + continue + } + if services, ok := r["services"].([]interface{}); ok { + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if sm["name"] == "network" && sm["status"] == "UP" { + regions = append(regions, name) + regionNames = append(regionNames, name) + break + } + } + } + } + } + + if len(regions) == 0 { + os.WriteFile("/tmp/floatingip_debug.json", []byte(`{"error":"no regions with network feature found"}`), 0644) + return dataLoadedMsg{data: nil, err: nil} + } + + allRegionIPs, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/floatingip", regions, true) + + var floatingIPs []map[string]interface{} + for i, ips := range allRegionIPs { + for _, ip := range ips { + if r, _ := ip["region"].(string); r == "" { + ip["region"] = regionNames[i] + } + floatingIPs = append(floatingIPs, ip) + } + } + + // Debug dump + dbg := map[string]interface{}{"regions": regionNames, "count": len(floatingIPs)} + if len(floatingIPs) > 0 { + dbg["first"] = floatingIPs[0] + } + if b, err2 := json.MarshalIndent(dbg, "", " "); err2 == nil { + os.WriteFile("/tmp/floatingip_debug.json", b, 0644) + } + + return dataLoadedMsg{data: floatingIPs, err: nil} +} + // fetchLoadBalancersData fetches load balancers at project level func (m Model) fetchLoadBalancersData() dataLoadedMsg { if m.cloudProject == "" { @@ -1572,6 +1647,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createVolumeBackupsTable(msg.data, m.width, m.height) case ProductNetworkPrivate: m.table = createPrivateNetworksTable(msg.data, m.width, m.height) + case ProductNetworkPublic: + m.table = createFloatingIPsTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -2041,6 +2118,72 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int return t } +// createFloatingIPsTable creates a table for floating/public IPs. +func createFloatingIPsTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "IP Address", Width: 18}, + {Title: "Region", Width: 18}, + {Title: "Associated Endpoint", Width: 40}, + } + + var rows []table.Row + for _, fip := range data { + ip := getString(fip, "ip") + if ip == "" { + ip = "-" + } + region := getString(fip, "region") + if region == "" { + region = "-" + } + + endpoint := "-" + if ae, ok := fip["associatedEntity"].(map[string]interface{}); ok { + atype := getString(ae, "type") + aip := getString(ae, "ip") + switch { + case atype != "" && aip != "": + endpoint = fmt.Sprintf("%s (%s)", atype, aip) + case atype != "": + endpoint = atype + case aip != "": + endpoint = aip + } + } + + rows = append(rows, table.Row{ip, region, endpoint}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + // createBlockStorageTable creates a nicely formatted table for block storage volumes. func createBlockStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index b4b8f150..d8cc51f9 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -817,7 +817,7 @@ type NetworkSubItem struct { func getNetworkSubItems() []NetworkSubItem { return []NetworkSubItem{ {Label: "Private Networks", Product: ProductNetworkPrivate, Path: "/networks/private", Enabled: true}, - {Label: "Public IPs", Product: ProductNetworkPublic, Path: "/networks/public", Enabled: true}, + {Label: "Public IPs", Product: ProductNetworkPublic, Path: "/networks/floatingip", Enabled: true}, {Label: "Gateways", Product: ProductNetworkGateway, Path: "/networks/gateway", Enabled: true}, {Label: "Load Balancers", Product: ProductNetworkLB, Path: "/loadbalancer", Enabled: true}, } From d5ed059a4fa9931cdb29de72af003433cfe34aa2 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 09:39:02 +0000 Subject: [PATCH 29/55] feat(browser): fixed visual of gateway Signed-off-by: olivier dubo --- internal/services/browser/api.go | 187 +++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 49 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 05cc2c6f..15a2c046 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1325,51 +1325,63 @@ func (m Model) fetchPublicNetworksData() dataLoadedMsg { } } -// fetchFloatingIPsData fetches floating IPs from regions that support the "network" feature -func (m Model) fetchFloatingIPsData() dataLoadedMsg { - os.WriteFile("/tmp/floatingip_called.txt", []byte("called project="+m.cloudProject), 0644) - if m.cloudProject == "" { - return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} - } - +// fetchNetworkRegions returns region names that have the "network" service UP. +func (m Model) fetchNetworkRegions() ([]string, error) { regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - // Fetch full region details (with services) to filter by "network" feature - regionDetails, err := httpLib.FetchExpandedArray(regionEndpoint, "") - if err != nil { - return dataLoadedMsg{err: err} + // Get plain list of region names + var allNames []string + if err := httpLib.Client.Get(regionEndpoint, &allNames); err != nil { + return nil, err } - // Debug: dump region details to understand structure - if b, err2 := json.MarshalIndent(regionDetails, "", " "); err2 == nil { - os.WriteFile("/tmp/floatingip_regions_debug.json", b, 0644) + // Fetch each region detail in parallel to check for "network" service + ids := make([]any, len(allNames)) + for i, n := range allNames { + ids[i] = n } + details, _ := httpLib.FetchObjectsParallel[map[string]any](regionEndpoint+"/%s", ids, true) - var regions []any - var regionNames []string - for _, r := range regionDetails { - name, _ := r["name"].(string) - if name == "" { + var result []string + for i, r := range details { + if r == nil { continue } + name := allNames[i] if services, ok := r["services"].([]interface{}); ok { for _, svc := range services { if sm, ok := svc.(map[string]interface{}); ok { if sm["name"] == "network" && sm["status"] == "UP" { - regions = append(regions, name) - regionNames = append(regionNames, name) + result = append(result, name) break } } } } } + return result, nil +} + +// fetchFloatingIPsData fetches floating IPs from network-capable regions +func (m Model) fetchFloatingIPsData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } - if len(regions) == 0 { - os.WriteFile("/tmp/floatingip_debug.json", []byte(`{"error":"no regions with network feature found"}`), 0644) + regionNames, err := m.fetchNetworkRegions() + if err != nil { + return dataLoadedMsg{err: err} + } + if len(regionNames) == 0 { return dataLoadedMsg{data: nil, err: nil} } + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + regions := make([]any, len(regionNames)) + for i, r := range regionNames { + regions[i] = r + } + allRegionIPs, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/floatingip", regions, true) var floatingIPs []map[string]interface{} @@ -1382,15 +1394,6 @@ func (m Model) fetchFloatingIPsData() dataLoadedMsg { } } - // Debug dump - dbg := map[string]interface{}{"regions": regionNames, "count": len(floatingIPs)} - if len(floatingIPs) > 0 { - dbg["first"] = floatingIPs[0] - } - if b, err2 := json.MarshalIndent(dbg, "", " "); err2 == nil { - os.WriteFile("/tmp/floatingip_debug.json", b, 0644) - } - return dataLoadedMsg{data: floatingIPs, err: nil} } @@ -1412,39 +1415,39 @@ func (m Model) fetchLoadBalancersData() dataLoadedMsg { } } -// fetchGatewaysData fetches gateways across all regions +// fetchGatewaysData fetches gateways from network-capable regions func (m Model) fetchGatewaysData() dataLoadedMsg { if m.cloudProject == "" { - return dataLoadedMsg{ - err: fmt.Errorf("no cloud project selected"), - } + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} } - var regionNames []string - regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(regionEndpoint, ®ionNames); err != nil { + regionNames, err := m.fetchNetworkRegions() + if err != nil { return dataLoadedMsg{err: err} } + if len(regionNames) == 0 { + return dataLoadedMsg{data: nil, err: nil} + } + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) regions := make([]any, len(regionNames)) for i, r := range regionNames { regions[i] = r } - allRegionGateways, err := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/gateway", regions, true) - if err != nil { - return dataLoadedMsg{err: err} - } + allRegionGateways, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/gateway", regions, true) var gateways []map[string]interface{} - for _, regionGateways := range allRegionGateways { - gateways = append(gateways, regionGateways...) + for i, regionGateways := range allRegionGateways { + for _, gw := range regionGateways { + if r, _ := gw["region"].(string); r == "" { + gw["region"] = regionNames[i] + } + gateways = append(gateways, gw) + } } - return dataLoadedMsg{ - data: gateways, - err: nil, - } + return dataLoadedMsg{data: gateways, err: nil} } // handleProjectsLoaded processes the loaded projects data @@ -1649,6 +1652,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createPrivateNetworksTable(msg.data, m.width, m.height) case ProductNetworkPublic: m.table = createFloatingIPsTable(msg.data, m.width, m.height) + case ProductNetworkGateway: + m.table = createGatewaysTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -2118,6 +2123,90 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int return t } +// createGatewaysTable creates a table for gateways. +func createGatewaysTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Name", Width: 22}, + {Title: "Region", Width: 16}, + {Title: "Size", Width: 6}, + {Title: "Private Network", Width: 36}, + {Title: "Public IP", Width: 18}, + {Title: "Private IP", Width: 34}, + {Title: "Status", Width: 10}, + } + + var rows []table.Row + for _, gw := range data { + name := getString(gw, "name") + region := getString(gw, "region") + size := getString(gw, "model") + status := getString(gw, "status") + + publicIP := "-" + if ei, ok := gw["externalInformation"].(map[string]interface{}); ok { + if ips, ok := ei["ips"].([]interface{}); ok && len(ips) > 0 { + if ipm, ok := ips[0].(map[string]interface{}); ok { + if v := getString(ipm, "ip"); v != "" { + publicIP = v + } + } + } + } + + privateNetwork := "-" + var privateIPs []string + if ifaces, ok := gw["interfaces"].([]interface{}); ok && len(ifaces) > 0 { + for _, iface := range ifaces { + if ifm, ok := iface.(map[string]interface{}); ok { + if v := getString(ifm, "ip"); v != "" { + privateIPs = append(privateIPs, v) + } + if privateNetwork == "-" { + if v := getString(ifm, "networkId"); v != "" { + privateNetwork = v + } + } + } + } + } + privateIP := "-" + if len(privateIPs) > 0 { + privateIP = strings.Join(privateIPs, ", ") + } + + rows = append(rows, table.Row{name, region, size, privateNetwork, publicIP, privateIP, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + // createFloatingIPsTable creates a table for floating/public IPs. func createFloatingIPsTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ From d140840852fcce55da355fce57b114c992daea01 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 09:59:41 +0000 Subject: [PATCH 30/55] feat(browser): fixed visual of loadbalancer Signed-off-by: olivier dubo --- internal/services/browser/api.go | 150 +++++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 9 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 15a2c046..cfe41e8a 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1397,22 +1397,80 @@ func (m Model) fetchFloatingIPsData() dataLoadedMsg { return dataLoadedMsg{data: floatingIPs, err: nil} } -// fetchLoadBalancersData fetches load balancers at project level +// fetchLoadBalancersData fetches load balancers from octavia-capable regions func (m Model) fetchLoadBalancersData() dataLoadedMsg { if m.cloudProject == "" { - return dataLoadedMsg{ - err: fmt.Errorf("no cloud project selected"), + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + + // Get region names then filter by octavialoadbalancer feature + var allNames []string + if err := httpLib.Client.Get(regionEndpoint, &allNames); err != nil { + return dataLoadedMsg{err: err} + } + ids := make([]any, len(allNames)) + for i, n := range allNames { + ids[i] = n + } + details, _ := httpLib.FetchObjectsParallel[map[string]any](regionEndpoint+"/%s", ids, true) + + var regions []any + var regionNames []string + for i, r := range details { + if r == nil { + continue } + if services, ok := r["services"].([]interface{}); ok { + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if sm["name"] == "octavialoadbalancer" && sm["status"] == "UP" { + regions = append(regions, allNames[i]) + regionNames = append(regionNames, allNames[i]) + break + } + } + } + } + } + + if len(regions) == 0 { + return dataLoadedMsg{data: nil, err: nil} } - var loadbalancers []map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/networkloadbalancer", m.cloudProject) - err := httpLib.Client.Get(endpoint, &loadbalancers) + allRegionLBs, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/loadbalancing/loadbalancer", regions, true) - return dataLoadedMsg{ - data: loadbalancers, - err: err, + // Build flavorId -> name map per region in parallel + allRegionFlavors, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/loadbalancing/flavor", regions, true) + flavorNameMap := make(map[string]string) + for _, flavors := range allRegionFlavors { + for _, f := range flavors { + if id, ok := f["id"].(string); ok { + if name, ok := f["name"].(string); ok { + flavorNameMap[id] = name + } + } + } } + + var lbs []map[string]interface{} + for i, regionLBs := range allRegionLBs { + for _, lb := range regionLBs { + if r, _ := lb["region"].(string); r == "" { + lb["region"] = regionNames[i] + } + // Replace flavorId with flavor name + if fid, ok := lb["flavorId"].(string); ok { + if fname, found := flavorNameMap[fid]; found { + lb["_flavorName"] = fname + } + } + lbs = append(lbs, lb) + } + } + + return dataLoadedMsg{data: lbs, err: nil} } // fetchGatewaysData fetches gateways from network-capable regions @@ -1654,6 +1712,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.table = createFloatingIPsTable(msg.data, m.width, m.height) case ProductNetworkGateway: m.table = createGatewaysTable(msg.data, m.width, m.height) + case ProductNetworkLB: + m.table = createLoadBalancersTable(msg.data, m.width, m.height) default: m.table = createGenericTable(msg.data, m.width, m.height) } @@ -2123,6 +2183,78 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int return t } +// createLoadBalancersTable creates a table for load balancers. +func createLoadBalancersTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "Name", Width: 22}, + {Title: "Region", Width: 16}, + {Title: "Size", Width: 14}, + {Title: "Private Network", Width: 36}, + {Title: "Public IP", Width: 18}, + {Title: "Private IP", Width: 16}, + {Title: "Supply Status", Width: 14}, + {Title: "Status", Width: 12}, + } + + var rows []table.Row + for _, lb := range data { + name := getString(lb, "name") + region := getString(lb, "region") + size := getString(lb, "_flavorName") + if size == "" { + size = getString(lb, "flavorId") + } + privateNetwork := getString(lb, "vipNetworkId") + privateIP := getString(lb, "vipAddress") + provisioning := getString(lb, "provisioningStatus") + status := getString(lb, "operatingStatus") + + publicIP := "-" + if fi, ok := lb["floatingIp"].(map[string]interface{}); ok { + if v := getString(fi, "ip"); v != "" { + publicIP = v + } + } + if privateNetwork == "" { + privateNetwork = "-" + } + if privateIP == "" { + privateIP = "-" + } + + rows = append(rows, table.Row{name, region, size, privateNetwork, publicIP, privateIP, provisioning, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + // createGatewaysTable creates a table for gateways. func createGatewaysTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ From 0630562430f14f26eecec67a08ce9aa56ac15477 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 10:04:07 +0000 Subject: [PATCH 31/55] feat(browser): Removed code use just for debug Signed-off-by: olivier dubo --- internal/services/browser/api.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index cfe41e8a..f136368b 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1568,12 +1568,6 @@ func (m Model) handleInstancesLoaded(msg instancesLoadedMsg) (tea.Model, tea.Cmd m.detailRefreshId = "" m.detailRefreshName = "" - // Debug: dump instances to file - if len(msg.instances) > 0 { - debugData, _ := json.MarshalIndent(msg.instances[0], "", " ") - os.WriteFile("/tmp/instance_debug.json", debugData, 0644) - } - // Preserve table cursor position during refresh currentCursor := m.table.Cursor() From 819e434e1c60ca8fb2769d4524a128ee496180d9 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 13:33:29 +0000 Subject: [PATCH 32/55] feat(browser): fixed navigation into principal with enter Signed-off-by: olivier dubo --- internal/services/browser/file_api.go | 44 +++++- internal/services/browser/manager.go | 213 +++++++++++++++++--------- 2 files changed, 183 insertions(+), 74 deletions(-) diff --git a/internal/services/browser/file_api.go b/internal/services/browser/file_api.go index 33b541a9..8d7a437f 100644 --- a/internal/services/browser/file_api.go +++ b/internal/services/browser/file_api.go @@ -153,16 +153,54 @@ func (m Model) createFileShare() tea.Cmd { } } +// fetchShareRegions returns region names that have the "share" service UP. +func (m Model) fetchShareRegions() ([]string, error) { + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + + var allNames []string + if err := httpLib.Client.Get(regionEndpoint, &allNames); err != nil { + return nil, err + } + + ids := make([]any, len(allNames)) + for i, n := range allNames { + ids[i] = n + } + details, _ := httpLib.FetchObjectsParallel[map[string]any](regionEndpoint+"/%s", ids, true) + + var result []string + for i, r := range details { + if r == nil { + continue + } + name := allNames[i] + if services, ok := r["services"].([]interface{}); ok { + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if sm["name"] == "share" && sm["status"] == "UP" { + result = append(result, name) + break + } + } + } + } + } + return result, nil +} + // fetchFileStorageData returns all NFS shares across every region. func (m Model) fetchFileStorageData() dataLoadedMsg { if m.cloudProject == "" { return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} } - var regionNames []string - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) - if err := httpLib.Client.Get(endpoint, ®ionNames); err != nil { + + regionNames, err := m.fetchShareRegions() + if err != nil { return dataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} } + if len(regionNames) == 0 { + return dataLoadedMsg{data: nil} + } type regionResult struct{ shares []map[string]interface{} } ch := make(chan regionResult, len(regionNames)) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index d8cc51f9..e650066d 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -360,6 +360,7 @@ type Model struct { inStorageSubNav bool // Whether the keyboard focus is in the storage sub-nav bar networkSubIdx int // Index in network sub-navigation inNetworkSubNav bool // Whether the keyboard focus is in the network sub-nav bar + inTableFocus bool // Whether the keyboard focus is in the table content (third navigation level) table table.Model detailData map[string]interface{} currentData []map[string]interface{} @@ -1656,10 +1657,19 @@ func (m Model) renderNavBar(width int) string { navItems := getNavItems() var items []string + isSubNavFocused := m.inStorageSubNav || m.inNetworkSubNav + isInSubContext := isSubNavFocused || m.inTableFocus + for i, nav := range navItems { var style lipgloss.Style - if i == m.navIdx { + if i == m.navIdx && !isInSubContext { + // Level 1 focus: full green highlight style = navItemSelectedStyle + } else if i == m.navIdx { + // Sub-level active: dim indicator so user knows which section they're in + style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#007733")). + Padding(0, 2) } else { style = navItemStyle } @@ -1716,8 +1726,13 @@ func (m Model) renderStorageSubNav(width int) string { for i, item := range subItems { var style lipgloss.Style - if i == activeSubIdx && m.inStorageSubNav { - // Focused selection: bright green + bold + label := item.Label + if i == activeSubIdx && m.inStorageSubNav && m.inTableFocus { + // Level 3: focus moved into the table — show arrow hint, dimmed + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) + label = "▼ " + item.Label + } else if i == activeSubIdx && m.inStorageSubNav { + // Level 2: sub-nav focused — bright green + bold style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) } else if i == activeSubIdx { // Active item, focus is on main nav — dimmed green @@ -1727,12 +1742,16 @@ func (m Model) renderStorageSubNav(width int) string { } else { style = subItemStyle } - items = append(items, style.Render(item.Label)) + items = append(items, style.Render(label)) } borderColor := lipgloss.Color("#333333") - if m.inStorageSubNav { + if m.inStorageSubNav && !m.inTableFocus { + // Level 2: sub-nav is focused — bright green border borderColor = lipgloss.Color("#00FF7F") + } else if m.inTableFocus { + // Level 3: focus is inside the table — dim the sub-nav border + borderColor = lipgloss.Color("#444444") } subBarStyle := lipgloss.NewStyle(). Padding(0, 1). @@ -1764,7 +1783,12 @@ func (m Model) renderNetworkSubNav(width int) string { for i, item := range subItems { var style lipgloss.Style - if i == activeSubIdx && m.inNetworkSubNav { + label := item.Label + if i == activeSubIdx && m.inNetworkSubNav && m.inTableFocus { + // Level 3: focus moved into the table — show arrow hint, dimmed + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) + label = "▼ " + item.Label + } else if i == activeSubIdx && m.inNetworkSubNav { style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) } else if i == activeSubIdx { style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) @@ -1773,12 +1797,16 @@ func (m Model) renderNetworkSubNav(width int) string { } else { style = subItemStyle } - items = append(items, style.Render(item.Label)) + items = append(items, style.Render(label)) } borderColor := lipgloss.Color("#333333") - if m.inNetworkSubNav { + if m.inNetworkSubNav && !m.inTableFocus { + // Level 2: sub-nav is focused — bright green border borderColor = lipgloss.Color("#00FF7F") + } else if m.inTableFocus { + // Level 3: focus is inside the table — dim the sub-nav border + borderColor = lipgloss.Color("#444444") } subBarStyle := lipgloss.NewStyle(). Padding(0, 1). @@ -1914,7 +1942,12 @@ func (m Model) renderContentBox(width int) string { // Combine title and content fullContent := title + "\n\n" + contentStr - return contentBoxStyle.Width(width - 4).Render(fullContent) + // Level 3 (inTableFocus): highlight content box border in green to show focus is inside the table + boxStyle := contentBoxStyle + if m.inTableFocus { + boxStyle = contentBoxStyle.BorderForeground(lipgloss.Color("#00FF7F")) + } + return boxStyle.Width(width - 4).Render(fullContent) } // renderLoadingView displays the loading screen @@ -4520,6 +4553,20 @@ func (m Model) renderTable() string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("No data available") } + // For sub-nav products (Storage / Network), hide the cursor highlight unless + // the user has entered table focus (Level 3 via Enter / ↓). + isSubNavProd := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB + if isSubNavProd && !m.inTableFocus { + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = lipgloss.NewStyle() // no highlight + m.table.SetStyles(s) + } + var content strings.Builder // Show filter indicator if filter is active (but not in edit mode) @@ -5057,20 +5104,20 @@ func (m Model) renderFooter() string { case TableView: if m.filterInput != "" { help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • v: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" - } else if m.inStorageSubNav { - help = "←→: Sub-menu • ↑: Back to main nav • /: Filter • v: Details • c: Create • d: Debug • p: Change Project • q: Quit" - } else if m.currentProduct == ProductStorageBlock { - help = "←→: Switch Product • ↓: Enter Sub-menu • ↑↓: Navigate • /: Filter • v: Details • c: Create • d: Debug • p: Change Project • q: Quit" + } else if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { + help = "↑↓: Navigate • v: Details • c: Create • /: Filter • d: Debug • Esc: Back to Sub-menu • q: Quit" + } else if m.inStorageSubNav || m.inNetworkSubNav { + help = "←→: Sub-menu • ↓/Enter: Enter Table • ↑/Esc: Back to main nav • d: Debug • p: Change Project • q: Quit" } else { - help = "←→: Switch Product • ↑↓: Navigate • /: Filter • v: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • Enter: Enter Sub-menu • ↑↓: Navigate • /: Filter • v: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: - if m.inStorageSubNav { - help = "←→: Sub-menu • ↑: Back to main nav • c: Create • d: Debug • p: Change Project • q: Quit" - } else if m.currentProduct == ProductStorageBlock { - help = "←→: Switch Product • ↓: Enter Sub-menu • c: Create • d: Debug • p: Change Project • q: Quit" + if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { + help = "c: Create • d: Debug • Esc: Back to Sub-menu • q: Quit" + } else if m.inStorageSubNav || m.inNetworkSubNav { + help = "←→: Sub-menu • Enter: Enter Table • ↑/Esc: Back to main nav • c: Create • d: Debug • p: Change Project • q: Quit" } else { - help = "←→: Switch Product • c: Create • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • Enter: Enter Sub-menu • c: Create • d: Debug • p: Change Project • q: Quit" } case DetailView: if m.currentProduct == ProductStorageObject && m.objectUserDetailView != nil { @@ -5289,8 +5336,8 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav (only when focused) - if m.inStorageSubNav && m.mode != DetailView { + // In storage sub-nav (only when focused, not in table) + if m.inStorageSubNav && !m.inTableFocus && m.mode != DetailView { subItems := getStorageSubItems() for i, item := range subItems { if item.Product == m.currentProduct { @@ -5301,8 +5348,8 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.storageSubIdx = (m.storageSubIdx - 1 + len(subItems)) % len(subItems) return m.loadStorageSubProduct() } - // In network sub-nav (only when focused) - if m.inNetworkSubNav && m.mode != DetailView { + // In network sub-nav (only when focused, not in table) + if m.inNetworkSubNav && !m.inTableFocus && m.mode != DetailView { subItems := getNetworkSubItems() for i, item := range subItems { if item.Product == m.currentProduct { @@ -5313,7 +5360,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.networkSubIdx = (m.networkSubIdx - 1 + len(subItems)) % len(subItems) return m.loadNetworkSubProduct() } - if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { + if m.mode != ProjectSelectView && m.currentProduct != ProductProjects && !m.inTableFocus { if m.navIdx > 0 { m.navIdx-- m.inStorageSubNav = false @@ -5348,9 +5395,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In storage sub-nav (only when focused) + // In storage sub-nav (only when focused, not in table) isStorageSubProduct2 := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive - if m.inStorageSubNav && isStorageSubProduct2 && m.mode != DetailView { + if m.inStorageSubNav && !m.inTableFocus && isStorageSubProduct2 && m.mode != DetailView { subItems := getStorageSubItems() for i, item := range subItems { if item.Product == m.currentProduct { @@ -5361,9 +5408,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.storageSubIdx = (m.storageSubIdx + 1) % len(subItems) return m.loadStorageSubProduct() } - // In network sub-nav (only when focused) + // In network sub-nav (only when focused, not in table) isNetworkSubProduct2 := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB - if m.inNetworkSubNav && isNetworkSubProduct2 && m.mode != DetailView { + if m.inNetworkSubNav && !m.inTableFocus && isNetworkSubProduct2 && m.mode != DetailView { subItems := getNetworkSubItems() for i, item := range subItems { if item.Product == m.currentProduct { @@ -5374,7 +5421,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.networkSubIdx = (m.networkSubIdx + 1) % len(subItems) return m.loadNetworkSubProduct() } - if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { + if m.mode != ProjectSelectView && m.currentProduct != ProductProjects && !m.inTableFocus { navItems := getNavItems() if m.navIdx < len(navItems)-1 { m.navIdx++ @@ -5432,6 +5479,20 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.applyTableFilter() return m, nil } + // Level 3 → Level 2: exit table focus (back to sub-nav focus) + if m.inTableFocus && (m.inStorageSubNav || m.inNetworkSubNav) && m.mode != DetailView && m.mode != WizardView { + m.inTableFocus = false + return m, nil + } + // Level 2 → Level 1: exit sub-nav focus (back to main nav) + if m.inStorageSubNav && !m.inTableFocus && m.mode != DetailView && m.mode != WizardView { + m.inStorageSubNav = false + return m, nil + } + if m.inNetworkSubNav && !m.inTableFocus && m.mode != DetailView && m.mode != WizardView { + m.inNetworkSubNav = false + return m, nil + } // Go back to node pools view from node pool detail view, or cancel action confirm if m.mode == NodePoolDetailView { if m.nodePoolDetailConfirm { @@ -5463,6 +5524,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "c": // Create resource - available in TableView, EmptyView, and NodePoolsView if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct != ProductProjects { + // Require table focus (Level 3) for sub-nav products + isSubNavProd := (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB) + if isSubNavProd && !m.inTableFocus { + return m, nil + } // If viewing S3 users tab, launch user creation wizard if m.currentProduct == ProductStorageObject && m.objectStorageTabIdx == 1 { m.mode = WizardView @@ -5488,24 +5554,30 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode != NodePoolsView && m.mode != NodePoolDetailView { navItems := getNavItems() if m.navIdx < len(navItems) && navItems[m.navIdx].Product == ProductStorage { - if m.inStorageSubNav { - // Go back to main nav - m.inStorageSubNav = false + if !m.inStorageSubNav { + // Level 1 → Level 2: enter sub-nav focus + m.inStorageSubNav = true + m.inTableFocus = false + return m.loadStorageSubProduct() + } else if !m.inTableFocus { + // Level 2 → Level 3: enter table focus + m.inTableFocus = true return m, nil } - // Drop into sub-nav - m.inStorageSubNav = true - return m.loadStorageSubProduct() + // Level 3: fall through to normal enter handling } if m.navIdx < len(navItems) && navItems[m.navIdx].Product == ProductNetworks { - if m.inNetworkSubNav { - // Go back to main nav - m.inNetworkSubNav = false + if !m.inNetworkSubNav { + // Level 1 → Level 2: enter sub-nav focus + m.inNetworkSubNav = true + m.inTableFocus = false + return m.loadNetworkSubProduct() + } else if !m.inTableFocus { + // Level 2 → Level 3: enter table focus + m.inTableFocus = true return m, nil } - // Drop into sub-nav - m.inNetworkSubNav = true - return m.loadNetworkSubProduct() + // Level 3: fall through to normal enter handling } } // Handle enter based on current mode @@ -5584,6 +5656,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "v": // In table view, show details if m.mode == TableView { + // Require table focus (Level 3) for sub-nav products + isSubNavProd := (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB) + if isSubNavProd && !m.inTableFocus { + return m, nil + } selectedRow := m.table.Cursor() if selectedRow >= 0 && selectedRow < len(m.currentData) { m.detailData = m.currentData[selectedRow] @@ -5644,40 +5721,29 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "up", "down", "j", "k": key := msg.String() - navItems := getNavItems() isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive isNetworkSubProduct := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB - if (key == "down" || key == "j") && !m.inStorageSubNav && m.mode != DetailView && - m.mode != ProjectSelectView && !isStorageSubProduct && navItems[m.navIdx].Product == ProductStorage { - m.inStorageSubNav = true - return m.loadStorageSubProduct() - } - if (key == "up" || key == "k") && m.inStorageSubNav && m.mode != DetailView { - if m.mode != TableView || m.table.Cursor() == 0 { - m.inStorageSubNav = false + isSubNavProduct := isStorageSubProduct || isNetworkSubProduct + + // In table focus (Level 3): up at row 0 → back to sub-nav focus (Level 2) + if (key == "up" || key == "k") && m.inTableFocus && isSubNavProduct && m.mode != DetailView { + if m.mode == TableView && m.table.Cursor() == 0 { + m.inTableFocus = false return m, nil } } - // ↑ on EmptyView/ComingSoonView for storage → focus sub-nav - if (key == "up" || key == "k") && isStorageSubProduct && !m.inStorageSubNav && (m.mode == EmptyView || m.mode == ComingSoonView) { - m.inStorageSubNav = true + // In sub-nav focus (Level 2): up → back to main nav (Level 1) + if (key == "up" || key == "k") && m.inStorageSubNav && !m.inTableFocus && m.mode != DetailView { + m.inStorageSubNav = false return m, nil } - // Down into network sub-nav - if (key == "down" || key == "j") && !m.inNetworkSubNav && m.mode != DetailView && - m.mode != ProjectSelectView && !isNetworkSubProduct && navItems[m.navIdx].Product == ProductNetworks { - m.inNetworkSubNav = true - return m.loadNetworkSubProduct() - } - if (key == "up" || key == "k") && m.inNetworkSubNav && m.mode != DetailView { - if m.mode != TableView || m.table.Cursor() == 0 { - m.inNetworkSubNav = false - return m, nil - } + if (key == "up" || key == "k") && m.inNetworkSubNav && !m.inTableFocus && m.mode != DetailView { + m.inNetworkSubNav = false + return m, nil } - // ↑ on EmptyView/ComingSoonView for network → focus sub-nav - if (key == "up" || key == "k") && isNetworkSubProduct && !m.inNetworkSubNav && (m.mode == EmptyView || m.mode == ComingSoonView) { - m.inNetworkSubNav = true + // In sub-nav focus (Level 2): down → enter table focus (Level 3) + if (key == "down" || key == "j") && isSubNavProduct && (m.inStorageSubNav || m.inNetworkSubNav) && !m.inTableFocus && m.mode != DetailView { + m.inTableFocus = true return m, nil } // Node pools list navigation @@ -5697,11 +5763,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // Table navigation (works in both ProjectSelectView and TableView) + // Table navigation: for sub-nav products requires Level 3 (inTableFocus); non-sub-nav always allowed if m.mode == TableView || m.mode == ProjectSelectView { - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd + if !isSubNavProduct || m.inTableFocus { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd + } } return m, nil @@ -7546,6 +7614,7 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { m.currentData = nil m.inStorageSubNav = false m.inNetworkSubNav = false + m.inTableFocus = false // For Networks, go to default sub-item (Private Networks = index 0) if currentNav.Product == ProductNetworks { @@ -7578,6 +7647,7 @@ func (m Model) loadStorageSubProduct() (Model, tea.Cmd) { m.detailData = nil m.currentData = nil m.volumeDetailView = nil + m.inTableFocus = false if !sub.Enabled { m.mode = ComingSoonView @@ -7594,6 +7664,7 @@ func (m Model) loadNetworkSubProduct() (Model, tea.Cmd) { m.currentProduct = sub.Product m.detailData = nil m.currentData = nil + m.inTableFocus = false if !sub.Enabled { m.mode = ComingSoonView From 5d22a42b786ff6a949e5ebefe5eca7018e595e5f Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 13:58:28 +0000 Subject: [PATCH 33/55] feat(browser): fixed navigation with arrows Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index e650066d..962bf9d6 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -5724,6 +5724,22 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive isNetworkSubProduct := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB isSubNavProduct := isStorageSubProduct || isNetworkSubProduct + navItems := getNavItems() + + // Level 1 → Level 2: ↓ from main nav enters sub-nav for Storage / Networks + if (key == "down" || key == "j") && !m.inStorageSubNav && !m.inNetworkSubNav && !m.inTableFocus && + m.mode != DetailView && m.mode != ProjectSelectView { + if navItems[m.navIdx].Product == ProductStorage { + m.inStorageSubNav = true + m.inTableFocus = false + return m.loadStorageSubProduct() + } + if navItems[m.navIdx].Product == ProductNetworks { + m.inNetworkSubNav = true + m.inTableFocus = false + return m.loadNetworkSubProduct() + } + } // In table focus (Level 3): up at row 0 → back to sub-nav focus (Level 2) if (key == "up" || key == "k") && m.inTableFocus && isSubNavProduct && m.mode != DetailView { From 33d8c59ec31dcea6c368a28048acc33341e364b7 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 4 May 2026 15:20:05 +0000 Subject: [PATCH 34/55] feat(browser): added create private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 121 +++- internal/services/browser/manager.go | 278 +++++++-- .../browser/private_network_wizard.go | 586 ++++++++++++++++++ .../services/browser/views/instances/table.go | 4 +- 4 files changed, 894 insertions(+), 95 deletions(-) create mode 100644 internal/services/browser/private_network_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index f136368b..1ca2f7cd 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1288,6 +1288,34 @@ func (m Model) fetchPrivateNetworksData() dataLoadedMsg { return dataLoadedMsg{err: err} } + // Also fetch region details in parallel to get the type (localzone vs region) + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + var allRegionNames []string + if err := httpLib.Client.Get(regionEndpoint, &allRegionNames); err == nil { + ids := make([]any, len(allRegionNames)) + for i, n := range allRegionNames { + ids[i] = n + } + regionDetails, _ := httpLib.FetchObjectsParallel[map[string]any](regionEndpoint+"/%s", ids, true) + regionTypeMap := make(map[string]string, len(allRegionNames)) + for i, d := range regionDetails { + if d != nil { + t, _ := d["type"].(string) + regionTypeMap[allRegionNames[i]] = t + } + } + // Embed _regionType on each network based on its first region + for i, n := range networks { + if regions, ok := n["regions"].([]interface{}); ok && len(regions) > 0 { + if rm, ok := regions[0].(map[string]interface{}); ok { + if reg := getString(rm, "region"); reg != "" { + networks[i]["_regionType"] = regionTypeMap[reg] + } + } + } + } + } + // Enrich with subnet data in parallel networkIDs := make([]any, len(networks)) for i, n := range networks { @@ -1307,6 +1335,47 @@ func (m Model) fetchPrivateNetworksData() dataLoadedMsg { } } +// fetchPrivateNetRegions returns regions suitable for private network creation with their type. +func (m Model) fetchPrivateNetRegions() ([]map[string]interface{}, error) { + regionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + var allNames []string + if err := httpLib.Client.Get(regionEndpoint, &allNames); err != nil { + return nil, err + } + ids := make([]any, len(allNames)) + for i, n := range allNames { + ids[i] = n + } + details, _ := httpLib.FetchObjectsParallel[map[string]any](regionEndpoint+"/%s", ids, true) + var result []map[string]interface{} + for i, d := range details { + if d == nil { + continue + } + name := allNames[i] + rtype, _ := d["type"].(string) + // Only include regions that support vrack / network + hasNetwork := false + if services, ok := d["services"].([]interface{}); ok { + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if sm["name"] == "network" && sm["status"] == "UP" { + hasNetwork = true + break + } + } + } + } + if hasNetwork { + result = append(result, map[string]interface{}{ + "name": name, + "type": rtype, + }) + } + } + return result, nil +} + // fetchPublicNetworksData fetches public networks at project level func (m Model) fetchPublicNetworksData() dataLoadedMsg { if m.cloudProject == "" { @@ -1701,7 +1770,22 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { case ProductStorageBackup: m.table = createVolumeBackupsTable(msg.data, m.width, m.height) case ProductNetworkPrivate: - m.table = createPrivateNetworksTable(msg.data, m.width, m.height) + // Split into vRack (tab 0) and Local Zones (tab 1) + var vRack, localZones []map[string]interface{} + for _, net := range msg.data { + if getString(net, "_regionType") == "localzone" { + localZones = append(localZones, net) + } else { + vRack = append(vRack, net) + } + } + m.privNetLocalZones = localZones + m.privNetTabIdx = 0 + // currentData = vRack slice (tab 0); full list kept in msg.data via normal path + m.currentData = vRack + m.table = createPrivateNetworksTable(vRack, m.width, m.height) + m.mode = TableView + return m, nil case ProductNetworkPublic: m.table = createFloatingIPsTable(msg.data, m.width, m.height) case ProductNetworkGateway: @@ -2067,16 +2151,15 @@ func createGenericTable(data []map[string]interface{}, width, height int) table. return t } -// createPrivateNetworksTable creates a table for private networks. +// createPrivateNetworksTable creates a table for private networks (one tab's worth of data). func createPrivateNetworksTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ {Title: "VLAN ID", Width: 8}, {Title: "Name", Width: 22}, {Title: "Location", Width: 20}, - {Title: "CIDR", Width: 20}, + {Title: "CIDR", Width: 18}, {Title: "Gateway", Width: 16}, {Title: "DHCP", Width: 6}, - {Title: "IP Allocated", Width: 33}, } var rows []table.Row @@ -2092,10 +2175,7 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int } } } - name := getString(net, "name") - - // Collect regions var locationParts []string if regions, ok := net["regions"].([]interface{}); ok { for _, r := range regions { @@ -2110,13 +2190,9 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int if location == "" { location = "-" } - - // Extract subnet data (first subnet) cidr := "-" gateway := "-" dhcp := "-" - ipAllocated := "-" - if subnets, ok := net["_subnets"].([]map[string]interface{}); ok && len(subnets) > 0 { sub := subnets[0] if v := getString(sub, "cidr"); v != "" { @@ -2127,32 +2203,21 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int } if v, ok := sub["dhcpEnabled"].(bool); ok { if v { - dhcp = "Active" + dhcp = "✓" } else { - dhcp = "Inactive" - } - } - // Show IP range from first ipPool - if pools, ok := sub["ipPools"].([]interface{}); ok && len(pools) > 0 { - if pm, ok := pools[0].(map[string]interface{}); ok { - start := getString(pm, "start") - end := getString(pm, "end") - if start != "" && end != "" { - ipAllocated = start + " - " + end - } + dhcp = "✗" } } } - - rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, ipAllocated}) + rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp}) } tableHeight := height - 15 if tableHeight < 5 { tableHeight = 5 } - if tableHeight > 20 { - tableHeight = 20 + if tableHeight > 25 { + tableHeight = 25 } t := table.New( @@ -2161,7 +2226,6 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int table.WithFocused(true), table.WithHeight(tableHeight), ) - s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). @@ -2173,7 +2237,6 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int Background(lipgloss.Color("57")). Bold(false) t.SetStyles(s) - return t } diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 962bf9d6..c60b15d2 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -139,6 +139,17 @@ const ( BackupWizardStepConfirm // confirm ) +const ( + // Private Network wizard steps (offset by 800) + PrivNetWizardStepRegion WizardStep = iota + 800 // choose location + PrivNetWizardStepName // network name + PrivNetWizardStepVlanID // VLAN ID (layer 2 option) + PrivNetWizardStepSubnet // configure subnet CIDR + PrivNetWizardStepDHCP // DHCP distribution options + PrivNetWizardStepGateway // gateway options + PrivNetWizardStepConfirm // confirm +) + // ProductType represents a product category type ProductType int @@ -346,6 +357,23 @@ type WizardData struct { backupName string // confirmed name backupNameInput string // input buffer for name backupConfirmBtnIdx int // 0=Create, 1=Cancel + // Private Network wizard fields + privNetRegions []map[string]interface{} // [{name, type}] + privNetRegionIdx int // selected region index + privNetNameInput string // network name input + privNetName string // confirmed name + privNetDefineVlan bool // whether user wants to set a VLAN ID + privNetVlanInput string // VLAN ID input ("" = auto) + privNetVlanID int // confirmed VLAN ID (0 = auto) + privNetEnableSubnet bool // whether to configure a subnet + privNetCIDRInput string // subnet CIDR input + privNetCIDR string // confirmed CIDR + privNetEnableDHCP bool // DHCP distribution enabled + privNetDHCPFieldIdx int // 0=toggle, 1=Next/Back + privNetGatewayMode int // 0=announce first CIDR IP, 1=assign explicit IP + privNetGatewayInput string // gateway IP input (mode 1) + privNetGateway string // confirmed gateway IP (mode 1) + privNetConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -411,6 +439,9 @@ type Model struct { // Object Storage tabs (0=Containers, 1=Users) objectStorageTabIdx int objectStorageUsers []map[string]interface{} + // Private Networks tabs (0=Régions vRack, 1=Local Zones) + privNetTabIdx int + privNetLocalZones []map[string]interface{} // S3 user creation result (for credentials display) s3CreatedUser map[string]interface{} s3CreatedCredentials map[string]interface{} @@ -780,6 +811,16 @@ type swiftRegionsLoadedMsg struct { regions []string } +type privNetRegionsLoadedMsg struct { + regions []map[string]interface{} + err error +} + +type privNetCreatedMsg struct { + network map[string]interface{} + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -977,6 +1018,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Chargement des volumes...", } return m, m.fetchBackupVolumes() + } else if msg.product == ProductNetworkPrivate { + m.mode = WizardView + m.wizard = WizardData{ + step: PrivNetWizardStepRegion, + privNetEnableDHCP: true, + privNetEnableSubnet: true, + privNetGatewayMode: 0, + privNetCIDRInput: "192.168.0.0/24", + isLoading: true, + loadingMessage: "Chargement des régions...", + } + return m, m.fetchPrivateNetRegionsCmd() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1145,6 +1198,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.backupVolumeIdx = 0 return m, nil + case privNetRegionsLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.privNetRegions = msg.regions + m.wizard.privNetRegionIdx = 0 + return m, nil + + case privNetCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + netName, _ := msg.network["name"].(string) + m.notification = fmt.Sprintf("✅ Réseau privé '%s' créé avec succès", netName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -1913,6 +1994,10 @@ func (m Model) renderContentBox(width int) string { if m.currentProduct == ProductStorageObject { contentStr = m.renderObjectStorageWithTabs(contentStr, width-6) } + // Add tabs for Private Networks + if m.currentProduct == ProductNetworkPrivate { + contentStr = m.renderPrivateNetworksWithTabs(contentStr, width-6) + } case DetailView: contentStr = m.renderDetailView(width - 6) case NodePoolsView: @@ -2769,7 +2854,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 700 { + if m.wizard.step >= 800 { + // Private Network wizard + steps = append(steps, "Région", "Nom", "VLAN", "Sous-réseau", "DHCP", "Passerelle", "Confirmer") + stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) + } else if m.wizard.step >= 700 { // Backup/Snapshot wizard steps = append(steps, "Volume", "Type", "Name", "Confirm") stepMapping = append(stepMapping, BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm) @@ -2953,6 +3042,21 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderS3UserWizardDescStep(width)) case S3UserWizardStepConfirm: content.WriteString(m.renderS3UserWizardConfirmStep(width)) + // Private Network wizard steps + case PrivNetWizardStepRegion: + content.WriteString(m.renderPrivNetWizardRegionStep(width)) + case PrivNetWizardStepName: + content.WriteString(m.renderPrivNetWizardNameStep(width)) + case PrivNetWizardStepVlanID: + content.WriteString(m.renderPrivNetWizardVlanStep(width)) + case PrivNetWizardStepSubnet: + content.WriteString(m.renderPrivNetWizardSubnetStep(width)) + case PrivNetWizardStepDHCP: + content.WriteString(m.renderPrivNetWizardDHCPStep(width)) + case PrivNetWizardStepGateway: + content.WriteString(m.renderPrivNetWizardGatewayStep(width)) + case PrivNetWizardStepConfirm: + content.WriteString(m.renderPrivNetWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: content.WriteString(m.renderBackupWizard(width)) @@ -4616,6 +4720,34 @@ func (m Model) renderObjectStorageWithTabs(tableContent string, width int) strin return content.String() } +func (m Model) renderPrivateNetworksWithTabs(tableContent string, width int) string { + var content strings.Builder + + tabActiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 2) + tabInactiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#333333")). + Foreground(lipgloss.Color("#888888")). + Padding(0, 2) + + tab1 := "Régions (vRack)" + tab2 := "Local Zones" + var t1, t2 string + if m.privNetTabIdx == 0 { + t1 = tabActiveStyle.Render(tab1) + t2 = tabInactiveStyle.Render(tab2) + } else { + t1 = tabInactiveStyle.Render(tab1) + t2 = tabActiveStyle.Render(tab2) + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, t1, " ", t2) + "\n\n") + content.WriteString(tableContent) + return content.String() +} + func (m Model) renderDeleteConfirmView() string { var content strings.Builder var instanceName string @@ -5103,13 +5235,17 @@ func (m Model) renderFooter() string { help = "↑↓: Navigate • Enter: Select Project • d: Set Default • q: Quit" case TableView: if m.filterInput != "" { - help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • v: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" + help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" } else if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { - help = "↑↓: Navigate • v: Details • c: Create • /: Filter • d: Debug • Esc: Back to Sub-menu • q: Quit" + tabHint := "" + if m.currentProduct == ProductNetworkPrivate || m.currentProduct == ProductStorageObject { + tabHint = " • t: Switch Tab" + } + help = "↑↓: Navigate • Enter: Details • c: Create • /: Filter" + tabHint + " • d: Debug • Esc: Back to Sub-menu • q: Quit" } else if m.inStorageSubNav || m.inNetworkSubNav { help = "←→: Sub-menu • ↓/Enter: Enter Table • ↑/Esc: Back to main nav • d: Debug • p: Change Project • q: Quit" } else { - help = "←→: Switch Product • Enter: Enter Sub-menu • ↑↓: Navigate • /: Filter • v: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" + help = "←→: Switch Product • Enter: Enter Sub-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { @@ -5436,16 +5572,25 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Toggle between Object Storage tabs (Containers / Users) if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct == ProductStorageObject { m.objectStorageTabIdx = (m.objectStorageTabIdx + 1) % 2 - // Rebuild table with appropriate data if m.objectStorageTabIdx == 0 { - // Show containers m.table = createObjectStorageTable(m.currentData, m.width, m.height) } else { - // Show users m.table = createObjectStorageUsersTable(m.objectStorageUsers, m.width, m.height) } return m, nil } + // Toggle between Private Networks tabs (vRack / Local Zones) + if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct == ProductNetworkPrivate { + m.privNetTabIdx = (m.privNetTabIdx + 1) % 2 + if m.privNetTabIdx == 0 { + // vRack tab: currentData was set to vRack on load; restore table from it + m.table = createPrivateNetworksTable(m.currentData, m.width, m.height) + } else { + // Local Zones tab + m.table = createPrivateNetworksTable(m.privNetLocalZones, m.width, m.height) + } + return m, nil + } return m, nil case "p": @@ -5652,68 +5797,56 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = NodePoolDetailView } } - return m, nil - case "v": - // In table view, show details + // Open detail view from table (replaces former 'v' key) if m.mode == TableView { - // Require table focus (Level 3) for sub-nav products isSubNavProd := (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB) - if isSubNavProd && !m.inTableFocus { - return m, nil - } - selectedRow := m.table.Cursor() - if selectedRow >= 0 && selectedRow < len(m.currentData) { - m.detailData = m.currentData[selectedRow] - m.currentItemName = getStringValue(m.detailData, "name", "Item") - m.mode = DetailView - - // If viewing a block storage volume, init the detail view - if m.currentProduct == ProductStorageBlock { - ctx := &views.Context{Width: m.width, Height: m.height} - m.volumeDetailView = block_storage.NewDetailView(ctx, m.detailData) - return m, nil - } + if !isSubNavProd || m.inTableFocus { + selectedRow := m.table.Cursor() + if selectedRow >= 0 && selectedRow < len(m.currentData) { + m.detailData = m.currentData[selectedRow] + m.currentItemName = getStringValue(m.detailData, "name", "Item") + m.mode = DetailView - // If viewing a file storage share, init the detail view - if m.currentProduct == ProductStorageFile { - ctx := &views.Context{Width: m.width, Height: m.height} - m.fileShareDetailView = file_storage.NewDetailView(ctx, m.detailData) - return m, nil - } - // If viewing a snapshot, init snapshot detail view - if m.currentProduct == ProductStorageSnapshot { - ctx := &views.Context{Width: m.width, Height: m.height} - m.snapshotDetailView = block_storage.NewSnapshotDetailView(ctx, m.detailData) - return m, nil - } - // If viewing a backup, init backup detail view - if m.currentProduct == ProductStorageBackup { - ctx := &views.Context{Width: m.width, Height: m.height} - m.backupDetailView = block_storage.NewBackupDetailView(ctx, m.detailData) - return m, nil - } - if m.currentProduct == ProductStorageObject { - if m.objectStorageTabIdx == 1 { - // Open user detail view — use objectStorageUsers for full data - selectedRow := m.table.Cursor() - if selectedRow < 0 || selectedRow >= len(m.objectStorageUsers) { + if m.currentProduct == ProductStorageBlock { + ctx := &views.Context{Width: m.width, Height: m.height} + m.volumeDetailView = block_storage.NewDetailView(ctx, m.detailData) + return m, nil + } + if m.currentProduct == ProductStorageFile { + ctx := &views.Context{Width: m.width, Height: m.height} + m.fileShareDetailView = file_storage.NewDetailView(ctx, m.detailData) + return m, nil + } + if m.currentProduct == ProductStorageSnapshot { + ctx := &views.Context{Width: m.width, Height: m.height} + m.snapshotDetailView = block_storage.NewSnapshotDetailView(ctx, m.detailData) + return m, nil + } + if m.currentProduct == ProductStorageBackup { + ctx := &views.Context{Width: m.width, Height: m.height} + m.backupDetailView = block_storage.NewBackupDetailView(ctx, m.detailData) + return m, nil + } + if m.currentProduct == ProductStorageObject { + if m.objectStorageTabIdx == 1 { + selectedRow := m.table.Cursor() + if selectedRow < 0 || selectedRow >= len(m.objectStorageUsers) { + return m, nil + } + ctx := &views.Context{Width: m.width, Height: m.height} + m.objectUserDetailView = object_storage.NewUserDetailView(ctx, m.objectStorageUsers[selectedRow]) + m.mode = DetailView return m, nil } ctx := &views.Context{Width: m.width, Height: m.height} - m.objectUserDetailView = object_storage.NewUserDetailView(ctx, m.objectStorageUsers[selectedRow]) - m.mode = DetailView + m.objectDetailView = object_storage.NewDetailView(ctx, m.detailData, m.objectStorageUsers) return m, nil } - ctx := &views.Context{Width: m.width, Height: m.height} - m.objectDetailView = object_storage.NewDetailView(ctx, m.detailData, m.objectStorageUsers) - return m, nil - } - - // If viewing a Kubernetes cluster, also load node pools - if m.currentProduct == ProductKubernetes { - kubeId := getStringValue(m.detailData, "id", "") - if kubeId != "" { - return m, m.fetchKubeNodePools(kubeId) + if m.currentProduct == ProductKubernetes { + kubeId := getStringValue(m.detailData, "id", "") + if kubeId != "" { + return m, m.fetchKubeNodePools(kubeId) + } } } } @@ -6111,13 +6244,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -6135,7 +6268,9 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 700 { + if m.wizard.step >= 800 { + returnPath = "/networks/private" + } else if m.wizard.step >= 700 { returnPath = "/storage/backup" } else if m.wizard.step >= 500 { returnPath = "/storage/object" @@ -6295,6 +6430,21 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleS3UserWizardConfirmKeys(key) case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: return m.handleBackupWizardKeys(msg) + // Private Network wizard steps + case PrivNetWizardStepRegion: + return m.handlePrivNetWizardRegionKeys(key) + case PrivNetWizardStepName: + return m.handlePrivNetWizardNameKeys(msg) + case PrivNetWizardStepVlanID: + return m.handlePrivNetWizardVlanKeys(msg) + case PrivNetWizardStepSubnet: + return m.handlePrivNetWizardSubnetKeys(msg) + case PrivNetWizardStepDHCP: + return m.handlePrivNetWizardDHCPKeys(key) + case PrivNetWizardStepGateway: + return m.handlePrivNetWizardGatewayKeys(msg) + case PrivNetWizardStepConfirm: + return m.handlePrivNetWizardConfirmKeys(key) } return m, nil } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go new file mode 100644 index 00000000..64d83f9a --- /dev/null +++ b/internal/services/browser/private_network_wizard.go @@ -0,0 +1,586 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net" + "net/url" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// ─── Private Network wizard ─────────────────────────────────────────────────── + +// fetchPrivateNetRegionsCmd returns a tea.Cmd that loads network-capable regions. +func (m Model) fetchPrivateNetRegionsCmd() tea.Cmd { + return func() tea.Msg { + regions, err := m.fetchPrivateNetRegions() + return privNetRegionsLoadedMsg{regions: regions, err: err} + } +} + +// createPrivateNetworkFromWizard sends the POST request to create the network (+optional subnet). +func (m Model) createPrivateNetworkFromWizard() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return privNetCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + + // Build region list from selected region + region := m.wizard.selectedRegion + + body := map[string]interface{}{ + "name": m.wizard.privNetName, + "regions": []string{region}, + } + if m.wizard.privNetVlanID > 0 { + body["vlanId"] = m.wizard.privNetVlanID + } + + var network map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) + if err := httpLib.Client.Post(endpoint, body, &network); err != nil { + return privNetCreatedMsg{err: fmt.Errorf("failed to create network: %w", err)} + } + + // Optionally create a subnet + if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" { + netID, _ := network["id"].(string) + if netID != "" { + startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR) + if cidrErr == nil { + subnetBody := map[string]interface{}{ + "dhcp": m.wizard.privNetEnableDHCP, + "network": m.wizard.privNetCIDR, + "noGateway": false, + "region": region, + "start": startIP, + "end": endIP, + } + var subnet map[string]interface{} + subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", + m.cloudProject, url.PathEscape(netID)) + // Best-effort: ignore subnet creation errors + _ = httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet) + } + } + } + + return privNetCreatedMsg{network: network} + } +} + +// ─── Render functions ───────────────────────────────────────────────────────── + +func (m Model) renderPrivNetWizardRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Choisir la localisation du réseau privé :") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("Chargement des régions...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + typeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + // Group by type + type entry struct { + name string + rtype string + } + var vrack, localz []entry + for _, r := range m.wizard.privNetRegions { + n, _ := r["name"].(string) + t, _ := r["type"].(string) + e := entry{name: n, rtype: t} + if t == "localzone" { + localz = append(localz, e) + } else { + vrack = append(vrack, e) + } + } + + allEntries := append(vrack, localz...) + sectionStart := len(vrack) + + for i, e := range allEntries { + if i == 0 { + content.WriteString(typeStyle.Render(" ── Régions (vRack) ──") + "\n") + } else if i == sectionStart { + content.WriteString(typeStyle.Render(" ── Local Zones ──") + "\n") + } + label := e.name + if i == m.wizard.privNetRegionIdx { + content.WriteString(selectedStyle.Render("▶ " + label) + "\n") + } else { + content.WriteString(dimStyle.Render(" " + label) + "\n") + } + } + + if len(allEntries) == 0 { + content.WriteString(dimStyle.Render(" Aucune région disponible.") + "\n") + } + + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Margin(1, 0, 0, 0). + Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardNameStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Nom du réseau privé :") + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(40) + content.WriteString(inputStyle.Render(m.wizard.privNetNameInput+"▌") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Tapez le nom • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardVlanStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + content.WriteString(titleStyle.Render("Option réseau layer 2 – VLAN ID :") + "\n\n") + + // Option 0 : auto + if !m.wizard.privNetDefineVlan { + content.WriteString(selectedStyle.Render("▶ Pas de VLAN (attribution automatique)") + "\n") + } else { + content.WriteString(dimStyle.Render(" Pas de VLAN (attribution automatique)") + "\n") + } + + // Option 1 : define VLAN + if m.wizard.privNetDefineVlan { + content.WriteString(selectedStyle.Render("▶ Définir un VLAN ID") + "\n\n") + content.WriteString(descStyle.Render(" VLAN ID (plage valide : 1 – 4094) :") + "\n") + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(20) + val := m.wizard.privNetVlanInput + if val == "" { + val = "(vide)" + } + content.WriteString(inputStyle.Render(val+"▌") + "\n\n") + } else { + content.WriteString(dimStyle.Render(" Définir un VLAN ID") + "\n\n") + } + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardSubnetStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + + content.WriteString(titleStyle.Render("Configurer le sous-réseau :") + "\n\n") + + // Toggle: enable/disable subnet + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + enableLabel := "○ Créer un sous-réseau" + if m.wizard.privNetEnableSubnet { + enableLabel = "● Créer un sous-réseau ✓" + } + content.WriteString(selectedStyle.Render(enableLabel) + "\n\n") + + if m.wizard.privNetEnableSubnet { + content.WriteString(descStyle.Render("CIDR du sous-réseau (ex : 192.168.0.0/24) :") + "\n") + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(30) + cidr := m.wizard.privNetCIDRInput + if cidr == "" { + cidr = "(vide)" + } + content.WriteString(inputStyle.Render(cidr+"▌") + "\n\n") + } else { + content.WriteString(dimStyle.Render(" Aucun sous-réseau ne sera créé.") + "\n\n") + } + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Space : Activer/Désactiver • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardDHCPStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + content.WriteString(titleStyle.Render("Options de distribution des adresses DHCP :") + "\n\n") + + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) + + dhcpLabel := "○ DHCP désactivé" + if m.wizard.privNetEnableDHCP { + dhcpLabel = "● DHCP activé ✓" + } + content.WriteString(selectedStyle.Render(dhcpLabel) + "\n\n") + + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + if m.wizard.privNetEnableDHCP { + content.WriteString(descStyle.Render("Le DHCP distribuera automatiquement des adresses IP aux instances.") + "\n\n") + } else { + content.WriteString(descStyle.Render("Les adresses IP devront être configurées manuellement.") + "\n\n") + } + + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Space/←→ : Basculer • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardGatewayStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + content.WriteString(titleStyle.Render("Options de passerelle réseau :") + "\n\n") + + // Option 0 + label0 := "Annoncer la première adresse d'un CIDR donné comme passerelle par défaut" + // Option 1 + label1 := "Assigner une Gateway et connectez-vous au réseau privé" + + if m.wizard.privNetGatewayMode == 0 { + content.WriteString(selectedStyle.Render("▶ "+label0) + "\n") + content.WriteString(dimStyle.Render(" "+label1) + "\n\n") + } else { + content.WriteString(dimStyle.Render(" "+label0) + "\n") + content.WriteString(selectedStyle.Render("▶ "+label1) + "\n\n") + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + content.WriteString(descStyle.Render(" Adresse IP de la passerelle (vide = première IP du CIDR) :") + "\n") + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(24) + gw := m.wizard.privNetGatewayInput + if gw == "" { + gw = "(auto)" + } + content.WriteString(inputStyle.Render(gw+"▌") + "\n\n") + } + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderPrivNetWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(titleStyle.Render("Confirmer la création du réseau privé :") + "\n\n") + content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.selectedRegion) + "\n") + content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.privNetName) + "\n") + + vlanStr := "automatique" + if m.wizard.privNetVlanID > 0 { + vlanStr = fmt.Sprintf("%d", m.wizard.privNetVlanID) + } + content.WriteString(labelStyle.Render(" VLAN ID :") + valueStyle.Render(vlanStr) + "\n") + + if m.wizard.privNetEnableSubnet { + content.WriteString(labelStyle.Render(" Sous-réseau (CIDR) :") + valueStyle.Render(m.wizard.privNetCIDR) + "\n") + dhcpStr := "désactivé" + if m.wizard.privNetEnableDHCP { + dhcpStr = "activé" + } + content.WriteString(labelStyle.Render(" DHCP :") + valueStyle.Render(dhcpStr) + "\n") + var gwStr string + if m.wizard.privNetGatewayMode == 0 { + gwStr = "Première IP du CIDR (auto)" + } else { + gwStr = "IP assignée" + if m.wizard.privNetGateway != "" { + gwStr = m.wizard.privNetGateway + } + } + content.WriteString(labelStyle.Render(" Passerelle :") + valueStyle.Render(gwStr) + "\n") + } else { + content.WriteString(labelStyle.Render(" Sous-réseau :") + valueStyle.Render("aucun") + "\n") + } + + content.WriteString("\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + if m.wizard.privNetConfirmBtnIdx == 1 { + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + } + content.WriteString(btnCreate + " " + btnCancel + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("←→ : Sélectionner • Enter : Confirmer • ← : Retour • Esc : Annuler")) + return content.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handlePrivNetWizardRegionKeys(key string) (tea.Model, tea.Cmd) { + total := len(m.wizard.privNetRegions) + switch key { + case "up", "k": + if m.wizard.privNetRegionIdx > 0 { + m.wizard.privNetRegionIdx-- + } + case "down", "j": + if m.wizard.privNetRegionIdx < total-1 { + m.wizard.privNetRegionIdx++ + } + case "enter": + if total > 0 { + r := m.wizard.privNetRegions[m.wizard.privNetRegionIdx] + m.wizard.selectedRegion, _ = r["name"].(string) + m.wizard.step = PrivNetWizardStepName + } + } + return m, nil +} + +func (m Model) handlePrivNetWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "enter": + name := strings.TrimSpace(m.wizard.privNetNameInput) + if name == "" { + m.wizard.errorMsg = "Le nom ne peut pas être vide" + return m, nil + } + m.wizard.privNetName = name + m.wizard.errorMsg = "" + m.wizard.step = PrivNetWizardStepVlanID + case "left": + m.wizard.step = PrivNetWizardStepRegion + case "backspace": + if len(m.wizard.privNetNameInput) > 0 { + m.wizard.privNetNameInput = m.wizard.privNetNameInput[:len(m.wizard.privNetNameInput)-1] + } + default: + if len(msg.Runes) > 0 { + m.wizard.privNetNameInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handlePrivNetWizardVlanKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "up", "k": + if m.wizard.privNetDefineVlan { + m.wizard.privNetDefineVlan = false + m.wizard.privNetVlanInput = "" + m.wizard.privNetVlanID = 0 + m.wizard.errorMsg = "" + } + case "down", "j": + if !m.wizard.privNetDefineVlan { + m.wizard.privNetDefineVlan = true + m.wizard.errorMsg = "" + } + case "enter": + if m.wizard.privNetDefineVlan { + input := strings.TrimSpace(m.wizard.privNetVlanInput) + if input == "" { + m.wizard.errorMsg = "Entrez un VLAN ID (1–4094)" + return m, nil + } + var v int + if _, err := fmt.Sscanf(input, "%d", &v); err != nil || v < 1 || v > 4094 { + m.wizard.errorMsg = "VLAN ID invalide (1–4094)" + return m, nil + } + m.wizard.privNetVlanID = v + } else { + m.wizard.privNetVlanID = 0 // auto + } + m.wizard.errorMsg = "" + m.wizard.step = PrivNetWizardStepSubnet + case "left": + m.wizard.step = PrivNetWizardStepName + case "backspace": + if m.wizard.privNetDefineVlan && len(m.wizard.privNetVlanInput) > 0 { + m.wizard.privNetVlanInput = m.wizard.privNetVlanInput[:len(m.wizard.privNetVlanInput)-1] + } + default: + if m.wizard.privNetDefineVlan && len(msg.Runes) > 0 { + r := msg.Runes[0] + if r >= '0' && r <= '9' { + m.wizard.privNetVlanInput += string(r) + } + } + } + return m, nil +} + +func (m Model) handlePrivNetWizardSubnetKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case " ": + m.wizard.privNetEnableSubnet = !m.wizard.privNetEnableSubnet + case "enter": + if m.wizard.privNetEnableSubnet { + cidr := strings.TrimSpace(m.wizard.privNetCIDRInput) + if cidr == "" { + m.wizard.errorMsg = "Le CIDR ne peut pas être vide" + return m, nil + } + m.wizard.privNetCIDR = cidr + } + m.wizard.errorMsg = "" + m.wizard.step = PrivNetWizardStepDHCP + case "left": + m.wizard.step = PrivNetWizardStepVlanID + case "backspace": + if m.wizard.privNetEnableSubnet && len(m.wizard.privNetCIDRInput) > 0 { + m.wizard.privNetCIDRInput = m.wizard.privNetCIDRInput[:len(m.wizard.privNetCIDRInput)-1] + } + default: + if m.wizard.privNetEnableSubnet && len(msg.Runes) > 0 { + m.wizard.privNetCIDRInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handlePrivNetWizardDHCPKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case " ", "h", "l": + if m.wizard.privNetEnableSubnet { + m.wizard.privNetEnableDHCP = !m.wizard.privNetEnableDHCP + } + case "enter": + m.wizard.step = PrivNetWizardStepGateway + case "left": + m.wizard.step = PrivNetWizardStepSubnet + } + return m, nil +} + +func (m Model) handlePrivNetWizardGatewayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "up", "k": + if m.wizard.privNetGatewayMode > 0 { + m.wizard.privNetGatewayMode-- + m.wizard.privNetGatewayInput = "" + m.wizard.privNetGateway = "" + m.wizard.errorMsg = "" + } + case "down", "j": + if m.wizard.privNetGatewayMode < 1 { + m.wizard.privNetGatewayMode++ + m.wizard.errorMsg = "" + } + case "enter": + if m.wizard.privNetGatewayMode == 1 { + m.wizard.privNetGateway = strings.TrimSpace(m.wizard.privNetGatewayInput) + } + m.wizard.errorMsg = "" + m.wizard.step = PrivNetWizardStepConfirm + case "left": + m.wizard.step = PrivNetWizardStepDHCP + case "backspace": + if m.wizard.privNetGatewayMode == 1 && len(m.wizard.privNetGatewayInput) > 0 { + m.wizard.privNetGatewayInput = m.wizard.privNetGatewayInput[:len(m.wizard.privNetGatewayInput)-1] + } + default: + if m.wizard.privNetGatewayMode == 1 && len(msg.Runes) > 0 { + m.wizard.privNetGatewayInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handlePrivNetWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.privNetConfirmBtnIdx = 0 + case "right", "l": + m.wizard.privNetConfirmBtnIdx = 1 + case "enter": + if m.wizard.privNetConfirmBtnIdx == 1 { + // Cancel button → go back to gateway step + m.wizard.step = PrivNetWizardStepGateway + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Création du réseau privé..." + return m, m.createPrivateNetworkFromWizard() + } + return m, nil +} + +// cidrToFirstLast derives the first usable IP (network+1) and last usable IP +// (broadcast-1) from an IPv4 CIDR block such as "192.168.0.0/24". +func cidrToFirstLast(cidr string) (first, last string, err error) { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return "", "", err + } + ip := ipNet.IP.To4() + if ip == nil { + return "", "", fmt.Errorf("only IPv4 CIDR is supported") + } + mask := []byte(ipNet.Mask) + if len(mask) == 16 { + mask = mask[12:] + } + broadcast := net.IP{ip[0] | ^mask[0], ip[1] | ^mask[1], ip[2] | ^mask[2], ip[3] | ^mask[3]} + firstIP := net.IP{ip[0], ip[1], ip[2], ip[3] + 1} + lastIP := net.IP{broadcast[0], broadcast[1], broadcast[2], broadcast[3] - 1} + return firstIP.String(), lastIP.String(), nil +} diff --git a/internal/services/browser/views/instances/table.go b/internal/services/browser/views/instances/table.go index b546ad5a..53314b75 100644 --- a/internal/services/browser/views/instances/table.go +++ b/internal/services/browser/views/instances/table.go @@ -159,7 +159,7 @@ func (v *TableView) HandleKey(msg tea.KeyMsg) tea.Cmd { case "/": v.filterMode = true return nil - case "v": + case "enter": // Return selected instance for detail view idx := v.table.Cursor() if idx >= 0 && idx < len(v.filteredData) { @@ -205,7 +205,7 @@ func (v *TableView) HelpText() string { if v.filterMode { return "Type to filter • Enter: Confirm • Esc: Cancel" } - return "↑↓: Navigate • /: Filter • v: Details • c: Create • d: Debug • p: Projects • q: Quit" + return "↑↓: Navigate • /: Filter • Enter: Details • c: Create • d: Debug • p: Projects • q: Quit" } // GetSelectedInstance returns the currently selected instance. From 0f0200574e82a65a83963fbbfcf6036d601a499f Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 5 May 2026 07:42:18 +0000 Subject: [PATCH 35/55] feat(browser): fixed navigation in pirvate network Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 68 +++++++++++++++++----------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index c60b15d2..aff60c8e 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -5237,11 +5237,13 @@ func (m Model) renderFooter() string { if m.filterInput != "" { help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" } else if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { - tabHint := "" - if m.currentProduct == ProductNetworkPrivate || m.currentProduct == ProductStorageObject { - tabHint = " • t: Switch Tab" + if m.currentProduct == ProductNetworkPrivate { + help = "↑↓: Navigate • ←→: Régions↔Local Zones • Enter: Détails • c: Create • /: Filter • d: Debug • Esc: Back • q: Quit" + } else if m.currentProduct == ProductStorageObject { + help = "↑↓: Navigate • ←→: Containers↔Users • Enter: Détails • c: Create • /: Filter • d: Debug • Esc: Back • q: Quit" + } else { + help = "↑↓: Navigate • Enter: Détails • c: Create • /: Filter • d: Debug • Esc: Back to Sub-menu • q: Quit" } - help = "↑↓: Navigate • Enter: Details • c: Create • /: Filter" + tabHint + " • d: Debug • Esc: Back to Sub-menu • q: Quit" } else if m.inStorageSubNav || m.inNetworkSubNav { help = "←→: Sub-menu • ↓/Enter: Enter Table • ↑/Esc: Back to main nav • d: Debug • p: Change Project • q: Quit" } else { @@ -5472,6 +5474,24 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus + if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && + (m.mode == TableView || m.mode == EmptyView) { + if m.privNetTabIdx > 0 { + m.privNetTabIdx = 0 + m.table = createPrivateNetworksTable(m.currentData, m.width, m.height) + } + return m, nil + } + // Object Storage: ←/→ switches between Containers and Users tabs when in table focus + if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && + (m.mode == TableView || m.mode == EmptyView) { + if m.objectStorageTabIdx > 0 { + m.objectStorageTabIdx = 0 + m.table = createObjectStorageTable(m.currentData, m.width, m.height) + } + return m, nil + } // In storage sub-nav (only when focused, not in table) if m.inStorageSubNav && !m.inTableFocus && m.mode != DetailView { subItems := getStorageSubItems() @@ -5531,6 +5551,24 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus + if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && + (m.mode == TableView || m.mode == EmptyView) { + if m.privNetTabIdx < 1 { + m.privNetTabIdx = 1 + m.table = createPrivateNetworksTable(m.privNetLocalZones, m.width, m.height) + } + return m, nil + } + // Object Storage: ←/→ switches between Containers and Users tabs when in table focus + if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && + (m.mode == TableView || m.mode == EmptyView) { + if m.objectStorageTabIdx < 1 { + m.objectStorageTabIdx = 1 + m.table = createObjectStorageUsersTable(m.objectStorageUsers, m.width, m.height) + } + return m, nil + } // In storage sub-nav (only when focused, not in table) isStorageSubProduct2 := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive if m.inStorageSubNav && !m.inTableFocus && isStorageSubProduct2 && m.mode != DetailView { @@ -5569,28 +5607,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "t": - // Toggle between Object Storage tabs (Containers / Users) - if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct == ProductStorageObject { - m.objectStorageTabIdx = (m.objectStorageTabIdx + 1) % 2 - if m.objectStorageTabIdx == 0 { - m.table = createObjectStorageTable(m.currentData, m.width, m.height) - } else { - m.table = createObjectStorageUsersTable(m.objectStorageUsers, m.width, m.height) - } - return m, nil - } - // Toggle between Private Networks tabs (vRack / Local Zones) - if (m.mode == TableView || m.mode == EmptyView) && m.currentProduct == ProductNetworkPrivate { - m.privNetTabIdx = (m.privNetTabIdx + 1) % 2 - if m.privNetTabIdx == 0 { - // vRack tab: currentData was set to vRack on load; restore table from it - m.table = createPrivateNetworksTable(m.currentData, m.width, m.height) - } else { - // Local Zones tab - m.table = createPrivateNetworksTable(m.privNetLocalZones, m.width, m.height) - } - return m, nil - } return m, nil case "p": From 1916f580e9e8f85c484f8937e6fd87e484070c09 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 5 May 2026 08:28:04 +0000 Subject: [PATCH 36/55] feat(browser): added details in private network and fix create for private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 51 ++++ internal/services/browser/gateway_wizard.go | 199 +++++++++++++++ internal/services/browser/manager.go | 235 +++++++++++++++++- .../browser/private_network_wizard.go | 102 ++++++-- 4 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 internal/services/browser/gateway_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 1ca2f7cd..8867746a 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1274,6 +1274,57 @@ func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.C ) } +// executePrivNetworkDelete deletes the currently selected private network. +func (m Model) executePrivNetworkDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return privNetDeletedMsg{err: fmt.Errorf("aucun réseau sélectionné")} + } + networkName := getString(m.detailData, "name") + if m.cloudProject == "" { + return privNetDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + + // The region-based API uses openstackId, not the vRack pn-XXXXX_N id. + // Each network has regions[].{region, openstackId} — delete from all regions. + regions, ok := m.detailData["regions"].([]interface{}) + if !ok || len(regions) == 0 { + return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf("aucune région trouvée pour ce réseau")} + } + + var lastErr error + for _, r := range regions { + rm, ok := r.(map[string]interface{}) + if !ok { + continue + } + region := getString(rm, "region") + openstackID := getString(rm, "openstackId") + if region == "" || openstackID == "" { + continue + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s", + m.cloudProject, + url.PathEscape(region), + url.PathEscape(openstackID), + ) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "409") || strings.Contains(errMsg, "Conflict") || strings.Contains(errMsg, "ports still in use") || strings.Contains(errMsg, "ports") { + return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf( + "impossible de supprimer le réseau : des ressources y sont encore attachées (instances, gateway, routeur). Détachez-les d'abord puis réessayez", + )} + } + lastErr = err + } + } + if lastErr != nil { + return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf("failed to delete network: %w", lastErr)} + } + return privNetDeletedMsg{networkName: networkName} + } +} + // fetchPrivateNetworksData fetches private networks and enriches each with subnet details func (m Model) fetchPrivateNetworksData() dataLoadedMsg { if m.cloudProject == "" { diff --git a/internal/services/browser/gateway_wizard.go b/internal/services/browser/gateway_wizard.go new file mode 100644 index 00000000..e10c385a --- /dev/null +++ b/internal/services/browser/gateway_wizard.go @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net/url" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// gatewayModels lists the available OVHcloud gateway sizes. +var gatewayModels = []string{"s", "m", "l", "xl", "2xl", "3xl"} + +// ─── API call ───────────────────────────────────────────────────────────────── + +// createGatewayFromWizard sends the POST request to create a gateway linked to the +// private network/subnet stored in the wizard state. +func (m Model) createGatewayFromWizard() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return gwCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + + model := gatewayModels[m.wizard.gwModelIdx] + body := map[string]interface{}{ + "model": model, + "name": m.wizard.gwName, + } + + endpoint := fmt.Sprintf( + "/v1/cloud/project/%s/region/%s/network/%s/subnet/%s/gateway", + m.cloudProject, + url.PathEscape(m.wizard.gwRegion), + url.PathEscape(m.wizard.gwNetworkID), + url.PathEscape(m.wizard.gwSubnetID), + ) + + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return gwCreatedMsg{err: fmt.Errorf("failed to create gateway: %w", err)} + } + return gwCreatedMsg{gateway: result} + } +} + +// ─── Render functions ───────────────────────────────────────────────────────── + +func (m Model) renderGwWizardModelStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Choisir le modèle de la Gateway :") + "\n\n") + content.WriteString(descStyle.Render( + fmt.Sprintf("Réseau : %s • Région : %s", m.wizard.gwNetworkName, m.wizard.gwRegion), + ) + "\n\n") + + for i, model := range gatewayModels { + if i == m.wizard.gwModelIdx { + content.WriteString(selectedStyle.Render("▶ " + strings.ToUpper(model)) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+strings.ToUpper(model)) + "\n") + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler")) + return content.String() +} + +func (m Model) renderGwWizardNameStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Nom de la Gateway :") + "\n\n") + content.WriteString(descStyle.Render( + fmt.Sprintf("Modèle : %s • Réseau : %s", strings.ToUpper(gatewayModels[m.wizard.gwModelIdx]), m.wizard.gwNetworkName), + ) + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(40) + content.WriteString(inputStyle.Render(m.wizard.gwNameInput+"▌") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Tapez le nom • Enter : Continuer • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderGwWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(titleStyle.Render("Confirmer la création de la Gateway :") + "\n\n") + content.WriteString(labelStyle.Render(" Réseau :") + valueStyle.Render(m.wizard.gwNetworkName) + "\n") + content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.gwRegion) + "\n") + content.WriteString(labelStyle.Render(" Modèle :") + valueStyle.Render(strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])) + "\n") + content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.gwName) + "\n") + content.WriteString("\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + if m.wizard.gwConfirmBtnIdx == 1 { + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + } + content.WriteString(btnCreate + " " + btnCancel + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler")) + return content.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handleGwWizardModelKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.gwModelIdx > 0 { + m.wizard.gwModelIdx-- + } + case "down", "j": + if m.wizard.gwModelIdx < len(gatewayModels)-1 { + m.wizard.gwModelIdx++ + } + case "enter": + m.wizard.step = GwWizardStepName + } + return m, nil +} + +func (m Model) handleGwWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "enter": + name := strings.TrimSpace(m.wizard.gwNameInput) + if name == "" { + m.wizard.errorMsg = "Le nom ne peut pas être vide" + return m, nil + } + m.wizard.gwName = name + m.wizard.errorMsg = "" + m.wizard.step = GwWizardStepConfirm + case "left": + m.wizard.step = GwWizardStepModel + case "backspace": + if len(m.wizard.gwNameInput) > 0 { + m.wizard.gwNameInput = m.wizard.gwNameInput[:len(m.wizard.gwNameInput)-1] + } + default: + if len(msg.Runes) > 0 { + m.wizard.gwNameInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handleGwWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.gwConfirmBtnIdx = 0 + case "right", "l": + m.wizard.gwConfirmBtnIdx = 1 + case "enter": + if m.wizard.gwConfirmBtnIdx == 1 { + // Cancel → go back to name step + m.wizard.step = GwWizardStepName + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Création de la Gateway..." + return m, m.createGatewayFromWizard() + } + return m, nil +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index aff60c8e..0609223d 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -150,6 +150,12 @@ const ( PrivNetWizardStepConfirm // confirm ) +const ( + GwWizardStepModel WizardStep = iota + 900 // choose gateway model + GwWizardStepName // gateway name + GwWizardStepConfirm // confirm +) + // ProductType represents a product category type ProductType int @@ -374,6 +380,16 @@ type WizardData struct { privNetGatewayInput string // gateway IP input (mode 1) privNetGateway string // confirmed gateway IP (mode 1) privNetConfirmBtnIdx int // 0=Create, 1=Cancel + + // Gateway wizard fields (launched from private network detail view) + gwNetworkID string + gwNetworkName string + gwRegion string + gwSubnetID string + gwModelIdx int // index into gatewayModels slice + gwNameInput string + gwName string + gwConfirmBtnIdx int // 0=Create, 1=Cancel } // Model represents the TUI application state @@ -821,6 +837,16 @@ type privNetCreatedMsg struct { err error } +type privNetDeletedMsg struct { + networkName string + err error +} + +type gwCreatedMsg struct { + gateway map[string]interface{} + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1025,7 +1051,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { privNetEnableDHCP: true, privNetEnableSubnet: true, privNetGatewayMode: 0, - privNetCIDRInput: "192.168.0.0/24", + privNetCIDRInput: "10.0.0.0/16", isLoading: true, loadingMessage: "Chargement des régions...", } @@ -1211,21 +1237,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case privNetCreatedMsg: m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(10 * time.Second) + m.mode = LoadingView + // Always reload the network list (network may have been created even if subnet failed) + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(10*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + netName, _ := msg.network["name"].(string) + m.notification = fmt.Sprintf("✅ Réseau privé '%s' créé avec succès", netName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case privNetDeletedMsg: if msg.err != nil { m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) m.notificationExpiry = time.Now().Add(8 * time.Second) m.mode = TableView return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - netName, _ := msg.network["name"].(string) - m.notification = fmt.Sprintf("✅ Réseau privé '%s' créé avec succès", netName) + m.notification = fmt.Sprintf("✅ Réseau privé '%s' supprimé avec succès", msg.networkName) m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil m.mode = LoadingView return m, tea.Batch( m.fetchDataForPath("/networks/private"), tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case gwCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = "✅ Gateway créée avec succès" + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/gateway"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -3057,6 +3119,13 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderPrivNetWizardGatewayStep(width)) case PrivNetWizardStepConfirm: content.WriteString(m.renderPrivNetWizardConfirmStep(width)) + // Gateway wizard steps + case GwWizardStepModel: + content.WriteString(m.renderGwWizardModelStep(width)) + case GwWizardStepName: + content.WriteString(m.renderGwWizardNameStep(width)) + case GwWizardStepConfirm: + content.WriteString(m.renderGwWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: content.WriteString(m.renderBackupWizard(width)) @@ -4823,6 +4892,8 @@ func (m Model) renderDetailView(width int) string { return m.fileShareDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductNetworkPrivate: + return m.renderPrivateNetworkDetail(width) case ProductStorageObject: if m.objectUserDetailView != nil { return m.objectUserDetailView.Render(width, 0) @@ -4997,6 +5068,86 @@ func (m Model) renderInstanceDetail(width int) string { return content.String() } +func (m Model) renderPrivateNetworkDetail(width int) string { + var content strings.Builder + + netID := getStringValue(m.detailData, "id", "N/A") + netName := getStringValue(m.detailData, "name", "Unknown") + vlanID := int(getFloatValue(m.detailData, "vlanId", 0)) + regionType := getStringValue(m.detailData, "_regionType", "region") + + // First region name + regionName := "N/A" + if regions, ok := m.detailData["regions"].([]interface{}); ok && len(regions) > 0 { + if rm, ok := regions[0].(map[string]interface{}); ok { + regionName = getStringValue(rm, "region", "N/A") + } + } + + // Subnets + cidr := "N/A" + gatewayIP := "N/A" + dhcpStr := "N/A" + if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok && len(subnets) > 0 { + cidr = getStringValue(subnets[0], "cidr", "N/A") + gatewayIP = getStringValue(subnets[0], "gatewayIp", "N/A") + if dhcp, ok := subnets[0]["dhcpEnabled"].(bool); ok { + if dhcp { dhcpStr = "activé" } else { dhcpStr = "désactivé" } + } + } + + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := (width - 6) / 2 + + // Info box + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("ID"), valueStyle.Render(truncate(netID, 36)))) + vlanStr := "automatique" + if vlanID > 0 { vlanStr = fmt.Sprintf("%d", vlanID) } + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("VLAN ID"), valueStyle.Render(vlanStr))) + rTypeLabel := "Région (vRack)" + if regionType == "localzone" { rTypeLabel = "Local Zone" } + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Type"), valueStyle.Render(rTypeLabel))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Région"), valueStyle.Render(regionName))) + infoBox := renderBox("Réseau privé", infoContent.String(), boxWidth) + + // Subnet box + var subContent strings.Builder + subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("CIDR"), valueStyle.Render(cidr))) + subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Passerelle"), valueStyle.Render(gatewayIP))) + subContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("DHCP"), valueStyle.Render(dhcpStr))) + subBox := renderBox("Sous-réseau", subContent.String(), boxWidth) + + _ = netName + + // Actions + actions := []string{"Supprimer", "Assigner une Gateway"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", subBox)) + return content.String() +} + func (m Model) renderKubernetesDetail(width int) string { var content strings.Builder @@ -5266,6 +5417,12 @@ func (m Model) renderFooter() string { help = m.snapshotDetailView.HelpText() } else if m.currentProduct == ProductStorageBackup && m.backupDetailView != nil { help = m.backupDetailView.HelpText() + } else if m.currentProduct == ProductNetworkPrivate { + if m.actionConfirm { + help = "Enter: Confirmer l'action • Esc: Annuler" + } else { + help = "←→: Sélectionner action • Enter: Exécuter • Esc: Retour à la liste • q: Quitter" + } } else if m.actionConfirm { help = "Enter: Confirm Action • Esc: Cancel" } else { @@ -5474,6 +5631,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway) + if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { + if m.selectedAction > 0 { + m.selectedAction-- + m.actionConfirm = false + } + return m, nil + } // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && (m.mode == TableView || m.mode == EmptyView) { @@ -5551,6 +5716,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway) + if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { + if m.selectedAction < 1 { + m.selectedAction++ + m.actionConfirm = false + } + return m, nil + } // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && (m.mode == TableView || m.mode == EmptyView) { @@ -5783,6 +5956,44 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true return m, nil } + } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { + // Private Network detail actions + switch m.selectedAction { + case 0: // Supprimer + if m.actionConfirm { + m.actionConfirm = false + return m, m.executePrivNetworkDelete() + } + m.actionConfirm = true + case 1: // Assigner une Gateway + m.actionConfirm = false + // Extract region and subnet from detailData + region := "" + if regions, ok := m.detailData["regions"].([]interface{}); ok && len(regions) > 0 { + if rm, ok := regions[0].(map[string]interface{}); ok { + region = getStringValue(rm, "region", "") + } + } + subnetID := "" + if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok && len(subnets) > 0 { + subnetID = getStringValue(subnets[0], "id", "") + } + if subnetID == "" { + m.notification = "❌ Ce réseau n'a pas de sous-réseau. Créez d'abord un sous-réseau." + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.mode = WizardView + m.wizard = WizardData{ + step: GwWizardStepModel, + gwNetworkID: getStringValue(m.detailData, "id", ""), + gwNetworkName: getStringValue(m.detailData, "name", ""), + gwRegion: region, + gwSubnetID: subnetID, + } + return m, nil + } + return m, nil } else if m.mode == ProjectSelectView { // Select project and go to products view selectedRow := m.table.Cursor() @@ -6260,13 +6471,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -6284,7 +6495,12 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 800 { + if m.wizard.step >= 900 { + // Gateway wizard: return to private network detail view + m.wizard = WizardData{} + m.mode = DetailView + return m, nil + } else if m.wizard.step >= 800 { returnPath = "/networks/private" } else if m.wizard.step >= 700 { returnPath = "/storage/backup" @@ -6461,6 +6677,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handlePrivNetWizardGatewayKeys(msg) case PrivNetWizardStepConfirm: return m.handlePrivNetWizardConfirmKeys(key) + // Gateway wizard steps + case GwWizardStepModel: + return m.handleGwWizardModelKeys(key) + case GwWizardStepName: + return m.handleGwWizardNameKeys(msg) + case GwWizardStepConfirm: + return m.handleGwWizardConfirmKeys(key) } return m, nil } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 64d83f9a..21d0ca5e 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -11,6 +11,7 @@ import ( "net" "net/url" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -54,22 +55,67 @@ func (m Model) createPrivateNetworkFromWizard() tea.Cmd { // Optionally create a subnet if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" { netID, _ := network["id"].(string) - if netID != "" { - startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR) - if cidrErr == nil { - subnetBody := map[string]interface{}{ - "dhcp": m.wizard.privNetEnableDHCP, - "network": m.wizard.privNetCIDR, - "noGateway": false, - "region": region, - "start": startIP, - "end": endIP, + if netID == "" { + return privNetCreatedMsg{err: fmt.Errorf("réseau créé mais ID manquant, impossible de créer le sous-réseau")} + } + + // Poll until the network region becomes ACTIVE (OVH creates async) + networkEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", + m.cloudProject, url.PathEscape(netID)) + const maxAttempts = 15 + regionActive := false + for i := 0; i < maxAttempts; i++ { + var netData map[string]interface{} + if err := httpLib.Client.Get(networkEndpoint, &netData); err == nil { + if regions, ok := netData["regions"].([]interface{}); ok { + for _, r := range regions { + if rMap, ok := r.(map[string]interface{}); ok { + if rMap["region"] == region { + if rMap["status"] == "ACTIVE" { + regionActive = true + } + } + } + } } - var subnet map[string]interface{} - subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", - m.cloudProject, url.PathEscape(netID)) - // Best-effort: ignore subnet creation errors - _ = httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet) + } + if regionActive { + break + } + time.Sleep(3 * time.Second) + } + if !regionActive { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais la région '%s' n'est pas devenue active à temps — sous-réseau non créé. Réessayez depuis l'interface OVH.", region), + } + } + + noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will use OVH Gateway service + + startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, !noGateway) + if cidrErr != nil { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais CIDR invalide ('%s'): %w", m.wizard.privNetCIDR, cidrErr), + } + } + + subnetBody := map[string]interface{}{ + "dhcp": m.wizard.privNetEnableDHCP, + "network": m.wizard.privNetCIDR, + "noGateway": noGateway, + "region": region, + "start": startIP, + "end": endIP, + } + var subnet map[string]interface{} + subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", + m.cloudProject, url.PathEscape(netID)) + if err := httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet); err != nil { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais échec du sous-réseau (%s, CIDR: %s): %w", netID, m.wizard.privNetCIDR, err), } } } @@ -219,7 +265,12 @@ func (m Model) renderPrivNetWizardSubnetStep(width int) string { content.WriteString(selectedStyle.Render(enableLabel) + "\n\n") if m.wizard.privNetEnableSubnet { - content.WriteString(descStyle.Render("CIDR du sous-réseau (ex : 192.168.0.0/24) :") + "\n") + // Build example CIDR: 10.{vlanId}.0.0/16, fallback to 10.0.0.0/16 + cidrExample := "10.0.0.0/16" + if m.wizard.privNetVlanID > 0 { + cidrExample = fmt.Sprintf("10.%d.0.0/16", m.wizard.privNetVlanID) + } + content.WriteString(descStyle.Render("CIDR du sous-réseau (ex : "+cidrExample+") :") + "\n") inputStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#00FF7F")). @@ -448,6 +499,12 @@ func (m Model) handlePrivNetWizardVlanKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) m.wizard.privNetVlanID = 0 // auto } m.wizard.errorMsg = "" + // Pre-fill CIDR input with a dynamic example based on VLAN ID + if m.wizard.privNetVlanID > 0 { + m.wizard.privNetCIDRInput = fmt.Sprintf("10.%d.0.0/16", m.wizard.privNetVlanID) + } else { + m.wizard.privNetCIDRInput = "10.0.0.0/16" + } m.wizard.step = PrivNetWizardStepSubnet case "left": m.wizard.step = PrivNetWizardStepName @@ -564,9 +621,10 @@ func (m Model) handlePrivNetWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { return m, nil } -// cidrToFirstLast derives the first usable IP (network+1) and last usable IP -// (broadcast-1) from an IPv4 CIDR block such as "192.168.0.0/24". -func cidrToFirstLast(cidr string) (first, last string, err error) { +// cidrToFirstLast derives the first usable IP and last usable IP (broadcast-1) +// from an IPv4 CIDR block. If reserveGateway is true, the first IP (network+1) +// is reserved for the gateway and the pool starts at network+2. +func cidrToFirstLast(cidr string, reserveGateway bool) (first, last string, err error) { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return "", "", err @@ -580,7 +638,11 @@ func cidrToFirstLast(cidr string) (first, last string, err error) { mask = mask[12:] } broadcast := net.IP{ip[0] | ^mask[0], ip[1] | ^mask[1], ip[2] | ^mask[2], ip[3] | ^mask[3]} - firstIP := net.IP{ip[0], ip[1], ip[2], ip[3] + 1} + offset := byte(1) + if reserveGateway { + offset = 2 // skip network+1 which is reserved for the gateway + } + firstIP := net.IP{ip[0], ip[1], ip[2], ip[3] + offset} lastIP := net.IP{broadcast[0], broadcast[1], broadcast[2], broadcast[3] - 1} return firstIP.String(), lastIP.String(), nil } From 1f40502c33ab49881845b14993b8ea8bd096a79c Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 5 May 2026 09:19:09 +0000 Subject: [PATCH 37/55] feat(browser): added gateway creation and details in private network Signed-off-by: olivier dubo --- internal/services/browser/gateway_wizard.go | 276 ++++++++++++++++-- internal/services/browser/manager.go | 175 +++++++++-- .../browser/private_network_wizard.go | 6 +- 3 files changed, 404 insertions(+), 53 deletions(-) diff --git a/internal/services/browser/gateway_wizard.go b/internal/services/browser/gateway_wizard.go index e10c385a..f565843c 100644 --- a/internal/services/browser/gateway_wizard.go +++ b/internal/services/browser/gateway_wizard.go @@ -9,6 +9,7 @@ package browser import ( "fmt" "net/url" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -19,10 +20,59 @@ import ( // gatewayModels lists the available OVHcloud gateway sizes. var gatewayModels = []string{"s", "m", "l", "xl", "2xl", "3xl"} -// ─── API call ───────────────────────────────────────────────────────────────── +// ─── API / fetch ────────────────────────────────────────────────────────────── -// createGatewayFromWizard sends the POST request to create a gateway linked to the -// private network/subnet stored in the wizard state. +// fetchGwRegions loads only the regions that have the "network" service UP. +func (m Model) fetchGwRegions() tea.Cmd { + return func() tea.Msg { + regions, err := m.fetchNetworkRegions() + if err != nil { + return gwRegionsLoadedMsg{err: err} + } + sort.Strings(regions) + return gwRegionsLoadedMsg{regions: regions} + } +} + +// fetchGwSubnet fetches the first subnet of a regional network and returns its ID. +func (m Model) fetchGwSubnet(networkID string) tea.Cmd { + region := m.wizard.gwRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet", + m.cloudProject, url.PathEscape(region), url.PathEscape(networkID)) + var subnets []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &subnets); err != nil || len(subnets) == 0 { + // No subnet exists yet — network selected but not ready for gateway + return gwSubnetLoadedMsg{subnetID: "", err: fmt.Errorf("ce réseau n'a pas de sous-réseau compatible (noGateway=true). Créez d'abord un sous-réseau via l'option 'OVH Gateway'.")} + } + return gwSubnetLoadedMsg{subnetID: getStringValue(subnets[0], "id", "")} + } +} + +func (m Model) fetchGwNetworks() tea.Cmd { + region := m.wizard.gwRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network", + m.cloudProject, url.PathEscape(region)) + var nets []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &nets); err != nil { + return gwNetworksLoadedMsg{err: err} + } + // Exclude the external/public network + var filtered []map[string]interface{} + for _, n := range nets { + name := getStringValue(n, "name", "") + if name != "" && name != "Ext-Net" { + filtered = append(filtered, n) + } + } + return gwNetworksLoadedMsg{networks: filtered} + } +} + +// createGatewayFromWizard creates the gateway. +// When a network+subnet are selected, uses the subnet-specific endpoint. +// Otherwise creates a standalone gateway. func (m Model) createGatewayFromWizard() tea.Cmd { return func() tea.Msg { if m.cloudProject == "" { @@ -35,16 +85,32 @@ func (m Model) createGatewayFromWizard() tea.Cmd { "name": m.wizard.gwName, } - endpoint := fmt.Sprintf( - "/v1/cloud/project/%s/region/%s/network/%s/subnet/%s/gateway", - m.cloudProject, - url.PathEscape(m.wizard.gwRegion), - url.PathEscape(m.wizard.gwNetworkID), - url.PathEscape(m.wizard.gwSubnetID), - ) + var endpoint string + if m.wizard.gwNetworkID != "" && m.wizard.gwSubnetID != "" { + // Attach to a specific subnet + endpoint = fmt.Sprintf( + "/v1/cloud/project/%s/region/%s/network/%s/subnet/%s/gateway", + m.cloudProject, + url.PathEscape(m.wizard.gwRegion), + url.PathEscape(m.wizard.gwNetworkID), + url.PathEscape(m.wizard.gwSubnetID), + ) + } else { + // Standalone gateway (no network attachment) + endpoint = fmt.Sprintf("/v1/cloud/project/%s/region/%s/gateway", + m.cloudProject, url.PathEscape(m.wizard.gwRegion)) + } var result map[string]interface{} if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "gateway IP must not be used by a port") || + strings.Contains(errMsg, "gateway IP") && strings.Contains(errMsg, "port") { + return gwCreatedMsg{err: fmt.Errorf( + "le sous-réseau a déjà une IP de passerelle en cours d'utilisation. " + + "L'OVH Gateway ne peut être créée que sur un sous-réseau configuré sans passerelle statique (mode 'OVH Gateway'). " + + "Recréez le réseau privé avec cette option")} + } return gwCreatedMsg{err: fmt.Errorf("failed to create gateway: %w", err)} } return gwCreatedMsg{gateway: result} @@ -53,6 +119,39 @@ func (m Model) createGatewayFromWizard() tea.Cmd { // ─── Render functions ───────────────────────────────────────────────────────── +func (m Model) renderGwWizardRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + content.WriteString(titleStyle.Render("Choisir la région :") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des régions...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.gwAvailableRegions) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Aucune région disponible.") + "\n") + } else { + for i, r := range m.wizard.gwAvailableRegions { + if i == m.wizard.gwRegionIdx { + content.WriteString(selectedStyle.Render("▶ "+r) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+r) + "\n") + } + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler")) + return content.String() +} + func (m Model) renderGwWizardModelStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) @@ -60,21 +159,23 @@ func (m Model) renderGwWizardModelStep(width int) string { dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - content.WriteString(titleStyle.Render("Choisir le modèle de la Gateway :") + "\n\n") - content.WriteString(descStyle.Render( - fmt.Sprintf("Réseau : %s • Région : %s", m.wizard.gwNetworkName, m.wizard.gwRegion), - ) + "\n\n") + content.WriteString(titleStyle.Render("Choisir la taille de la Gateway :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Région : %s", m.wizard.gwRegion)) + "\n\n") for i, model := range gatewayModels { if i == m.wizard.gwModelIdx { - content.WriteString(selectedStyle.Render("▶ " + strings.ToUpper(model)) + "\n") + content.WriteString(selectedStyle.Render("▶ "+strings.ToUpper(model)) + "\n") } else { content.WriteString(dimStyle.Render(" "+strings.ToUpper(model)) + "\n") } } + backHint := "← : Retour " + if len(m.wizard.gwAvailableRegions) == 0 { + backHint = "" + } content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler")) + Render("↑↓ Naviguer • Enter : Sélectionner • "+backHint+"• Esc : Annuler")) return content.String() } @@ -85,7 +186,8 @@ func (m Model) renderGwWizardNameStep(width int) string { content.WriteString(titleStyle.Render("Nom de la Gateway :") + "\n\n") content.WriteString(descStyle.Render( - fmt.Sprintf("Modèle : %s • Réseau : %s", strings.ToUpper(gatewayModels[m.wizard.gwModelIdx]), m.wizard.gwNetworkName), + fmt.Sprintf("Région : %s • Taille : %s", + m.wizard.gwRegion, strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])), ) + "\n\n") if m.wizard.errorMsg != "" { @@ -102,6 +204,47 @@ func (m Model) renderGwWizardNameStep(width int) string { return content.String() } +func (m Model) renderGwWizardNetworkStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Attacher à un réseau privé :") + "\n\n") + content.WriteString(descStyle.Render( + fmt.Sprintf("Région : %s • Taille : %s • Nom : %s", + m.wizard.gwRegion, strings.ToUpper(gatewayModels[m.wizard.gwModelIdx]), m.wizard.gwName), + ) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des réseaux...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.gwAvailableNetworks) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + Render("Aucun réseau privé disponible dans cette région.") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("← : Retour • Esc : Annuler")) + } else { + for i, net := range m.wizard.gwAvailableNetworks { + name := getStringValue(net, "name", getStringValue(net, "id", "unknown")) + if i == m.wizard.gwNetworkIdx { + content.WriteString(selectedStyle.Render("▶ "+name) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+name) + "\n") + } + } + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + } + return content.String() +} + func (m Model) renderGwWizardConfirmStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) @@ -109,10 +252,12 @@ func (m Model) renderGwWizardConfirmStep(width int) string { valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) content.WriteString(titleStyle.Render("Confirmer la création de la Gateway :") + "\n\n") - content.WriteString(labelStyle.Render(" Réseau :") + valueStyle.Render(m.wizard.gwNetworkName) + "\n") content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.gwRegion) + "\n") - content.WriteString(labelStyle.Render(" Modèle :") + valueStyle.Render(strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])) + "\n") + content.WriteString(labelStyle.Render(" Taille :") + valueStyle.Render(strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])) + "\n") content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.gwName) + "\n") + if m.wizard.gwNetworkName != "" { + content.WriteString(labelStyle.Render(" Réseau :") + valueStyle.Render(m.wizard.gwNetworkName) + "\n") + } content.WriteString("\n") if m.wizard.isLoading { @@ -137,6 +282,32 @@ func (m Model) renderGwWizardConfirmStep(width int) string { // ─── Key handlers ───────────────────────────────────────────────────────────── +func (m Model) handleGwWizardRegionKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.gwRegionIdx > 0 { + m.wizard.gwRegionIdx-- + } + case "down", "j": + if m.wizard.gwRegionIdx < len(m.wizard.gwAvailableRegions)-1 { + m.wizard.gwRegionIdx++ + } + case "enter": + if len(m.wizard.gwAvailableRegions) > 0 { + m.wizard.gwRegion = m.wizard.gwAvailableRegions[m.wizard.gwRegionIdx] + // Attach mode: look up openstackId + subnetId for the selected region + if m.wizard.gwNetworkRegionMap != nil { + if regionData, ok := m.wizard.gwNetworkRegionMap[m.wizard.gwRegion]; ok { + m.wizard.gwNetworkID = regionData["openstackId"] + m.wizard.gwSubnetID = regionData["subnetId"] + } + } + m.wizard.step = GwWizardStepModel + } + } + return m, nil +} + func (m Model) handleGwWizardModelKeys(key string) (tea.Model, tea.Cmd) { switch key { case "up", "k": @@ -149,6 +320,11 @@ func (m Model) handleGwWizardModelKeys(key string) (tea.Model, tea.Cmd) { } case "enter": m.wizard.step = GwWizardStepName + case "left": + // Only go back to region step if we came from there (full wizard) + if len(m.wizard.gwAvailableRegions) > 0 { + m.wizard.step = GwWizardStepRegion + } } return m, nil } @@ -164,7 +340,17 @@ func (m Model) handleGwWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.wizard.gwName = name m.wizard.errorMsg = "" - m.wizard.step = GwWizardStepConfirm + // Attach mode (region map populated) or standalone with network pre-set + // → skip network-selection step, go straight to confirm + if m.wizard.gwNetworkRegionMap != nil || (m.wizard.gwNetworkID != "" && len(m.wizard.gwAvailableNetworks) == 0) { + m.wizard.step = GwWizardStepConfirm + return m, nil + } + // Full wizard: load networks for selected region + m.wizard.step = GwWizardStepNetwork + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des réseaux..." + return m, m.fetchGwNetworks() case "left": m.wizard.step = GwWizardStepModel case "backspace": @@ -179,6 +365,47 @@ func (m Model) handleGwWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) handleGwWizardNetworkKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.gwNetworkIdx > 0 { + m.wizard.gwNetworkIdx-- + } + case "down", "j": + if m.wizard.gwNetworkIdx < len(m.wizard.gwAvailableNetworks)-1 { + m.wizard.gwNetworkIdx++ + } + case "enter": + if len(m.wizard.gwAvailableNetworks) > 0 { + net := m.wizard.gwAvailableNetworks[m.wizard.gwNetworkIdx] + m.wizard.gwNetworkID = getStringValue(net, "id", "") + m.wizard.gwNetworkName = getStringValue(net, "name", getStringValue(net, "id", "unknown")) + // Regional network API returns subnets as a list of ID strings — extract the first one + subnetID := "" + if subnets, ok := net["subnets"].([]interface{}); ok && len(subnets) > 0 { + switch v := subnets[0].(type) { + case string: + subnetID = v + case map[string]interface{}: + subnetID = getStringValue(v, "id", "") + } + } + if subnetID != "" { + m.wizard.gwSubnetID = subnetID + m.wizard.step = GwWizardStepConfirm + return m, nil + } + // Subnet IDs not embedded — fetch them + m.wizard.isLoading = true + m.wizard.loadingMessage = "Vérification du sous-réseau..." + return m, m.fetchGwSubnet(m.wizard.gwNetworkID) + } + case "left": + m.wizard.step = GwWizardStepName + } + return m, nil +} + func (m Model) handleGwWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { switch key { case "left", "h": @@ -187,8 +414,12 @@ func (m Model) handleGwWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { m.wizard.gwConfirmBtnIdx = 1 case "enter": if m.wizard.gwConfirmBtnIdx == 1 { - // Cancel → go back to name step - m.wizard.step = GwWizardStepName + // Cancel → back to previous step + if m.wizard.gwNetworkRegionMap != nil || (m.wizard.gwNetworkID != "" && len(m.wizard.gwAvailableNetworks) == 0) { + m.wizard.step = GwWizardStepName + } else { + m.wizard.step = GwWizardStepNetwork + } return m, nil } m.wizard.isLoading = true @@ -197,3 +428,4 @@ func (m Model) handleGwWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { } return m, nil } + diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 0609223d..ad30c210 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -151,9 +151,11 @@ const ( ) const ( - GwWizardStepModel WizardStep = iota + 900 // choose gateway model - GwWizardStepName // gateway name - GwWizardStepConfirm // confirm + GwWizardStepRegion WizardStep = iota + 900 // select region + GwWizardStepModel // select model/size + GwWizardStepName // enter name + GwWizardStepNetwork // select private network + GwWizardStepConfirm // confirm + create ) // ProductType represents a product category @@ -381,15 +383,22 @@ type WizardData struct { privNetGateway string // confirmed gateway IP (mode 1) privNetConfirmBtnIdx int // 0=Create, 1=Cancel - // Gateway wizard fields (launched from private network detail view) - gwNetworkID string - gwNetworkName string - gwRegion string - gwSubnetID string - gwModelIdx int // index into gatewayModels slice - gwNameInput string - gwName string - gwConfirmBtnIdx int // 0=Create, 1=Cancel + // Gateway wizard fields + gwNetworkID string + gwNetworkName string + gwRegion string + gwSubnetID string + gwModelIdx int // index into gatewayModels slice + gwNameInput string + gwName string + gwConfirmBtnIdx int // 0=Create, 1=Cancel + gwAvailableRegions []string // regions fetched from API + gwRegionIdx int // selected region index + gwAvailableNetworks []map[string]interface{} // networks in selected region + gwNetworkIdx int // selected network index + // Attach mode: populated when launched from private network detail + // maps region name -> {"openstackId": "...", "subnetId": "..."} + gwNetworkRegionMap map[string]map[string]string } // Model represents the TUI application state @@ -847,6 +856,21 @@ type gwCreatedMsg struct { err error } +type gwRegionsLoadedMsg struct { + regions []string + err error +} + +type gwNetworksLoadedMsg struct { + networks []map[string]interface{} + err error +} + +type gwSubnetLoadedMsg struct { + subnetID string + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1056,6 +1080,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Chargement des régions...", } return m, m.fetchPrivateNetRegionsCmd() + } else if msg.product == ProductNetworkGateway { + m.mode = WizardView + m.wizard = WizardData{ + step: GwWizardStepRegion, + isLoading: true, + loadingMessage: "Chargement des régions...", + } + return m, m.fetchGwRegions() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1288,6 +1320,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case gwRegionsLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.gwAvailableRegions = msg.regions + m.wizard.gwRegionIdx = 0 + return m, nil + + case gwNetworksLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.gwAvailableNetworks = msg.networks + m.wizard.gwNetworkIdx = 0 + return m, nil + + case gwSubnetLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.gwSubnetID = msg.subnetID + m.wizard.step = GwWizardStepConfirm + return m, nil + case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -3120,10 +3185,14 @@ func (m Model) renderWizardView(width int) string { case PrivNetWizardStepConfirm: content.WriteString(m.renderPrivNetWizardConfirmStep(width)) // Gateway wizard steps + case GwWizardStepRegion: + content.WriteString(m.renderGwWizardRegionStep(width)) case GwWizardStepModel: content.WriteString(m.renderGwWizardModelStep(width)) case GwWizardStepName: content.WriteString(m.renderGwWizardNameStep(width)) + case GwWizardStepNetwork: + content.WriteString(m.renderGwWizardNetworkStep(width)) case GwWizardStepConfirm: content.WriteString(m.renderGwWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps @@ -5967,29 +6036,73 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true case 1: // Assigner une Gateway m.actionConfirm = false - // Extract region and subnet from detailData - region := "" - if regions, ok := m.detailData["regions"].([]interface{}); ok && len(regions) > 0 { - if rm, ok := regions[0].(map[string]interface{}); ok { - region = getStringValue(rm, "region", "") + // Build region list from the network's available regions + var regionNames []string + regionMap := make(map[string]map[string]string) + if regions, ok := m.detailData["regions"].([]interface{}); ok { + for _, rv := range regions { + rm, ok := rv.(map[string]interface{}) + if !ok { + continue + } + regionName := getStringValue(rm, "region", "") + openstackID := getStringValue(rm, "openstackId", "") + if regionName == "" || openstackID == "" { + continue + } + // Find subnet for this region's OpenStack network + subnetID := "" + if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok { + for _, s := range subnets { + if getStringValue(s, "networkId", "") == openstackID { + subnetID = getStringValue(s, "id", "") + break + } + } + // Fallback: just take first subnet + if subnetID == "" && len(subnets) > 0 { + subnetID = getStringValue(subnets[0], "id", "") + } + } + regionNames = append(regionNames, regionName) + regionMap[regionName] = map[string]string{ + "openstackId": openstackID, + "subnetId": subnetID, + } } } - subnetID := "" - if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok && len(subnets) > 0 { - subnetID = getStringValue(subnets[0], "id", "") + if len(regionNames) == 0 { + m.notification = "❌ Aucune région compatible : l'OVH Gateway ne peut être ajoutée qu'à des sous-réseaux créés sans passerelle (mode 'OVH Gateway'). Recréez le réseau avec ce mode." + m.notificationExpiry = time.Now().Add(10 * time.Second) + return m, tea.Tick(10*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - if subnetID == "" { - m.notification = "❌ Ce réseau n'a pas de sous-réseau. Créez d'abord un sous-réseau." - m.notificationExpiry = time.Now().Add(5 * time.Second) - return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + netName := getStringValue(m.detailData, "name", "") + if len(regionNames) == 1 { + // Only one region: pre-select and go directly to model + rd := regionMap[regionNames[0]] + if rd["subnetId"] == "" { + m.notification = "❌ Ce réseau n'a pas de sous-réseau. Créez d'abord un sous-réseau." + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.mode = WizardView + m.wizard = WizardData{ + step: GwWizardStepModel, + gwNetworkName: netName, + gwRegion: regionNames[0], + gwNetworkID: rd["openstackId"], + gwSubnetID: rd["subnetId"], + gwNetworkRegionMap: regionMap, + } + return m, nil } + // Multiple regions: let user choose m.mode = WizardView m.wizard = WizardData{ - step: GwWizardStepModel, - gwNetworkID: getStringValue(m.detailData, "id", ""), - gwNetworkName: getStringValue(m.detailData, "name", ""), - gwRegion: region, - gwSubnetID: subnetID, + step: GwWizardStepRegion, + gwNetworkName: netName, + gwAvailableRegions: regionNames, + gwNetworkRegionMap: regionMap, } return m, nil } @@ -6678,10 +6791,14 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case PrivNetWizardStepConfirm: return m.handlePrivNetWizardConfirmKeys(key) // Gateway wizard steps + case GwWizardStepRegion: + return m.handleGwWizardRegionKeys(key) case GwWizardStepModel: return m.handleGwWizardModelKeys(key) case GwWizardStepName: return m.handleGwWizardNameKeys(msg) + case GwWizardStepNetwork: + return m.handleGwWizardNetworkKeys(key) case GwWizardStepConfirm: return m.handleGwWizardConfirmKeys(key) } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 21d0ca5e..56da9a7c 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -91,9 +91,11 @@ func (m Model) createPrivateNetworkFromWizard() tea.Cmd { } } - noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will use OVH Gateway service + noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will attach OVH Gateway service - startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, !noGateway) + // Always reserve network+1 for the gateway IP (whether static or OVH Gateway). + // Not reserving it causes a 409 conflict when the Gateway service tries to claim that IP. + startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, true) if cidrErr != nil { return privNetCreatedMsg{ network: network, From 50eede343f4868d97b5265a7bcf7dc97efc403fc Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 5 May 2026 13:35:40 +0000 Subject: [PATCH 38/55] feat(browser): added gateway settings and fixed visual in private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 24 ++ internal/services/browser/gateway_wizard.go | 48 +++- internal/services/browser/manager.go | 178 +++++++++++++- .../browser/private_network_wizard.go | 224 ++++++++++++------ 4 files changed, 393 insertions(+), 81 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 8867746a..2fb6452a 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1274,6 +1274,30 @@ func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.C ) } +// executeGatewayDelete deletes the currently selected gateway. +func (m Model) executeGatewayDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return gwDeletedMsg{err: fmt.Errorf("aucune gateway sélectionnée")} + } + if m.cloudProject == "" { + return gwDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + gwID := getString(m.detailData, "id") + gwName := getString(m.detailData, "name") + region := getString(m.detailData, "region") + if gwID == "" || region == "" { + return gwDeletedMsg{err: fmt.Errorf("ID ou région de la gateway introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/gateway/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(gwID)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return gwDeletedMsg{gatewayName: gwName, err: fmt.Errorf("failed to delete gateway: %w", err)} + } + return gwDeletedMsg{gatewayName: gwName} + } +} + // executePrivNetworkDelete deletes the currently selected private network. func (m Model) executePrivNetworkDelete() tea.Cmd { return func() tea.Msg { diff --git a/internal/services/browser/gateway_wizard.go b/internal/services/browser/gateway_wizard.go index f565843c..3e22be75 100644 --- a/internal/services/browser/gateway_wizard.go +++ b/internal/services/browser/gateway_wizard.go @@ -17,8 +17,13 @@ import ( httpLib "github.com/ovh/ovhcloud-cli/internal/http" ) -// gatewayModels lists the available OVHcloud gateway sizes. -var gatewayModels = []string{"s", "m", "l", "xl", "2xl", "3xl"} +// gatewayModels lists the gateway sizes available across all regions. +var gatewayModels = []string{"s", "m", "l", "xl", "2xl"} + +// gwActiveModels returns the model list (always the static one; no per-region API exists). +func (m Model) gwActiveModels() []string { + return gatewayModels +} // ─── API / fetch ────────────────────────────────────────────────────────────── @@ -79,7 +84,13 @@ func (m Model) createGatewayFromWizard() tea.Cmd { return gwCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} } - model := gatewayModels[m.wizard.gwModelIdx] + model := func() string { + models := m.gwActiveModels() + if m.wizard.gwModelIdx < len(models) { + return models[m.wizard.gwModelIdx] + } + return "s" + }() body := map[string]interface{}{ "model": model, "name": m.wizard.gwName, @@ -162,7 +173,8 @@ func (m Model) renderGwWizardModelStep(width int) string { content.WriteString(titleStyle.Render("Choisir la taille de la Gateway :") + "\n\n") content.WriteString(descStyle.Render(fmt.Sprintf("Région : %s", m.wizard.gwRegion)) + "\n\n") - for i, model := range gatewayModels { + models := m.gwActiveModels() + for i, model := range models { if i == m.wizard.gwModelIdx { content.WriteString(selectedStyle.Render("▶ "+strings.ToUpper(model)) + "\n") } else { @@ -184,10 +196,16 @@ func (m Model) renderGwWizardNameStep(width int) string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + models := m.gwActiveModels() + modelName := "" + if m.wizard.gwModelIdx < len(models) { + modelName = strings.ToUpper(models[m.wizard.gwModelIdx]) + } + content.WriteString(titleStyle.Render("Nom de la Gateway :") + "\n\n") content.WriteString(descStyle.Render( fmt.Sprintf("Région : %s • Taille : %s", - m.wizard.gwRegion, strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])), + m.wizard.gwRegion, modelName), ) + "\n\n") if m.wizard.errorMsg != "" { @@ -214,7 +232,13 @@ func (m Model) renderGwWizardNetworkStep(width int) string { content.WriteString(titleStyle.Render("Attacher à un réseau privé :") + "\n\n") content.WriteString(descStyle.Render( fmt.Sprintf("Région : %s • Taille : %s • Nom : %s", - m.wizard.gwRegion, strings.ToUpper(gatewayModels[m.wizard.gwModelIdx]), m.wizard.gwName), + m.wizard.gwRegion, func() string { + models := m.gwActiveModels() + if m.wizard.gwModelIdx < len(models) { + return strings.ToUpper(models[m.wizard.gwModelIdx]) + } + return "" + }(), m.wizard.gwName), ) + "\n\n") if m.wizard.isLoading { @@ -253,7 +277,13 @@ func (m Model) renderGwWizardConfirmStep(width int) string { content.WriteString(titleStyle.Render("Confirmer la création de la Gateway :") + "\n\n") content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.gwRegion) + "\n") - content.WriteString(labelStyle.Render(" Taille :") + valueStyle.Render(strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])) + "\n") + content.WriteString(labelStyle.Render(" Taille :") + valueStyle.Render(func() string { + models := m.gwActiveModels() + if m.wizard.gwModelIdx < len(models) { + return strings.ToUpper(models[m.wizard.gwModelIdx]) + } + return "" + }()) + "\n") content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.gwName) + "\n") if m.wizard.gwNetworkName != "" { content.WriteString(labelStyle.Render(" Réseau :") + valueStyle.Render(m.wizard.gwNetworkName) + "\n") @@ -302,6 +332,7 @@ func (m Model) handleGwWizardRegionKeys(key string) (tea.Model, tea.Cmd) { m.wizard.gwSubnetID = regionData["subnetId"] } } + m.wizard.gwModelIdx = 0 m.wizard.step = GwWizardStepModel } } @@ -309,13 +340,14 @@ func (m Model) handleGwWizardRegionKeys(key string) (tea.Model, tea.Cmd) { } func (m Model) handleGwWizardModelKeys(key string) (tea.Model, tea.Cmd) { + models := m.gwActiveModels() switch key { case "up", "k": if m.wizard.gwModelIdx > 0 { m.wizard.gwModelIdx-- } case "down", "j": - if m.wizard.gwModelIdx < len(gatewayModels)-1 { + if m.wizard.gwModelIdx < len(models)-1 { m.wizard.gwModelIdx++ } case "enter": diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index ad30c210..4287fe6b 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -382,6 +382,8 @@ type WizardData struct { privNetGatewayInput string // gateway IP input (mode 1) privNetGateway string // confirmed gateway IP (mode 1) privNetConfirmBtnIdx int // 0=Create, 1=Cancel + privNetIsLocalZone bool // true when selected region is a local zone + privNetUsedVlanIDs map[int]bool // VLAN IDs already in use (to validate before API call) // Gateway wizard fields gwNetworkID string @@ -399,6 +401,7 @@ type WizardData struct { // Attach mode: populated when launched from private network detail // maps region name -> {"openstackId": "...", "subnetId": "..."} gwNetworkRegionMap map[string]map[string]string + gwAttachMode bool // true when wizard was launched from private network detail view } // Model represents the TUI application state @@ -871,6 +874,11 @@ type gwSubnetLoadedMsg struct { err error } +type gwDeletedMsg struct { + gatewayName string + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1070,12 +1078,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.fetchBackupVolumes() } else if msg.product == ProductNetworkPrivate { m.mode = WizardView + // Collect already-used VLAN IDs from the currently loaded networks list + usedVlans := make(map[int]bool) + for _, net := range m.currentData { + switch v := net["vlanId"].(type) { + case float64: + usedVlans[int(v)] = true + case int: + usedVlans[v] = true + } + } m.wizard = WizardData{ - step: PrivNetWizardStepRegion, - privNetEnableDHCP: true, - privNetEnableSubnet: true, + step: PrivNetWizardStepRegion, + privNetEnableDHCP: true, + privNetEnableSubnet: true, privNetGatewayMode: 0, - privNetCIDRInput: "10.0.0.0/16", + privNetCIDRInput: "10.0.0.0/16", + privNetUsedVlanIDs: usedVlans, isLoading: true, loadingMessage: "Chargement des régions...", } @@ -1304,7 +1323,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) - case gwCreatedMsg: + case gwDeletedMsg: m.wizard = WizardData{} if msg.err != nil { m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) @@ -1312,8 +1331,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = TableView return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } + m.notification = fmt.Sprintf("✅ Gateway '%s' supprimée avec succès", msg.gatewayName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/gateway"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case gwCreatedMsg: + attachMode := m.wizard.gwAttachMode + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + if attachMode { + m.mode = DetailView + } else { + m.mode = TableView + } + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } m.notification = "✅ Gateway créée avec succès" m.notificationExpiry = time.Now().Add(5 * time.Second) + if attachMode { + // Return to private network list + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } m.mode = LoadingView return m, tea.Batch( m.fetchDataForPath("/networks/gateway"), @@ -2031,7 +2080,13 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 700 { + if m.wizard.step >= 900 { + // Gateway wizard + titleText = " 🌐 Create Gateway " + } else if m.wizard.step >= 800 { + // Private Network wizard + titleText = " 🌐 Create Private Network " + } else if m.wizard.step >= 700 { // Backup/Snapshot wizard titleText = " 💾 Create Volume Backup / Snapshot " } else if m.wizard.step >= 600 { @@ -4963,6 +5018,8 @@ func (m Model) renderDetailView(width int) string { return m.renderGenericDetail(width) case ProductNetworkPrivate: return m.renderPrivateNetworkDetail(width) + case ProductNetworkGateway: + return m.renderGatewayDetail(width) case ProductStorageObject: if m.objectUserDetailView != nil { return m.objectUserDetailView.Render(width, 0) @@ -5137,6 +5194,103 @@ func (m Model) renderInstanceDetail(width int) string { return content.String() } +func (m Model) renderGatewayDetail(width int) string { + var content strings.Builder + + gwID := getStringValue(m.detailData, "id", "N/A") + gwName := getStringValue(m.detailData, "name", "Unknown") + region := getStringValue(m.detailData, "region", "N/A") + model := getStringValue(m.detailData, "model", "N/A") + status := getStringValue(m.detailData, "status", "N/A") + + publicIP := "N/A" + if ei, ok := m.detailData["externalInformation"].(map[string]interface{}); ok { + if ips, ok := ei["ips"].([]interface{}); ok && len(ips) > 0 { + if ipm, ok := ips[0].(map[string]interface{}); ok { + if v := getStringValue(ipm, "ip", ""); v != "" { + publicIP = v + } + } + } + } + + var privateIPs []string + privateNetwork := "N/A" + if ifaces, ok := m.detailData["interfaces"].([]interface{}); ok { + for _, iface := range ifaces { + if ifm, ok := iface.(map[string]interface{}); ok { + if v := getStringValue(ifm, "ip", ""); v != "" { + privateIPs = append(privateIPs, v) + } + if privateNetwork == "N/A" { + if v := getStringValue(ifm, "networkId", ""); v != "" { + privateNetwork = v + } + } + } + } + } + privateIP := "N/A" + if len(privateIPs) > 0 { + privateIP = strings.Join(privateIPs, ", ") + } + + statusIcon := "🟢" + statusStyle := statusRunningStyle + if strings.ToLower(status) != "active" { + statusIcon = "🟡" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + } + + labelSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) + valueSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := (width - 6) / 2 + if boxWidth < 35 { + boxWidth = 35 + } + + // Info box + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Status"), statusStyle.Render(statusIcon+" "+status))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(gwID, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Région"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Model"), valueSt.Render(model))) + infoBox := renderBox("Gateway "+gwName, infoContent.String(), boxWidth) + + // Network box + var netContent strings.Builder + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP publique"), valueSt.Render(publicIP))) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP privée"), valueSt.Render(truncate(privateIP, 36)))) + netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Réseau privé"), valueSt.Render(truncate(privateNetwork, 36)))) + netBox := renderBox("Réseau", netContent.String(), boxWidth) + + // Actions + actions := []string{"Supprimer"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", netBox)) + return content.String() +} + func (m Model) renderPrivateNetworkDetail(width int) string { var content strings.Builder @@ -6025,6 +6179,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true return m, nil } + } else if m.mode == DetailView && m.currentProduct == ProductNetworkGateway { + switch m.selectedAction { + case 0: + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeGatewayDelete() + } + m.actionConfirm = true + } + return m, nil } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction { @@ -6093,6 +6257,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { gwNetworkID: rd["openstackId"], gwSubnetID: rd["subnetId"], gwNetworkRegionMap: regionMap, + gwAttachMode: true, } return m, nil } @@ -6103,6 +6268,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { gwNetworkName: netName, gwAvailableRegions: regionNames, gwNetworkRegionMap: regionMap, + gwAttachMode: true, } return m, nil } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 56da9a7c..2293738a 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "net/url" + "sort" "strings" "time" @@ -38,86 +39,150 @@ func (m Model) createPrivateNetworkFromWizard() tea.Cmd { // Build region list from selected region region := m.wizard.selectedRegion - body := map[string]interface{}{ - "name": m.wizard.privNetName, - "regions": []string{region}, - } - if m.wizard.privNetVlanID > 0 { - body["vlanId"] = m.wizard.privNetVlanID - } - var network map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) - if err := httpLib.Client.Post(endpoint, body, &network); err != nil { - return privNetCreatedMsg{err: fmt.Errorf("failed to create network: %w", err)} - } - // Optionally create a subnet - if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" { - netID, _ := network["id"].(string) + if m.wizard.privNetIsLocalZone { + // Local zones use the regional network API (isolated, not vRack-based) + body := map[string]interface{}{ + "name": m.wizard.privNetName, + } + if m.wizard.privNetVlanID > 0 { + body["vlanId"] = m.wizard.privNetVlanID + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network", + m.cloudProject, url.PathEscape(region)) + var op map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &op); err != nil { + return privNetCreatedMsg{err: fmt.Errorf("failed to create network: %w", err)} + } + // Regional API may return an operation object with resourceId or a network with id + netID := getString(op, "resourceId") if netID == "" { - return privNetCreatedMsg{err: fmt.Errorf("réseau créé mais ID manquant, impossible de créer le sous-réseau")} + netID = getString(op, "id") + } + if netID == "" { + return privNetCreatedMsg{err: fmt.Errorf("réseau créé mais ID manquant dans la réponse")} + } + network = map[string]interface{}{"id": netID, "name": m.wizard.privNetName} + + // Optionally create a subnet using the regional subnet API + if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" { + parts := strings.Split(m.wizard.privNetCIDR, "/") + ipParts := strings.Split(parts[0], ".") + var gatewayIP string + if len(ipParts) == 4 { + gatewayIP = ipParts[0] + "." + ipParts[1] + "." + ipParts[2] + ".1" + } + subnetBody := map[string]interface{}{ + "name": m.wizard.privNetName + "-subnet", + "cidr": m.wizard.privNetCIDR, + "ipVersion": 4, + "enableDhcp": m.wizard.privNetEnableDHCP, + "enableGatewayIp": gatewayIP != "", + } + if gatewayIP != "" { + subnetBody["gatewayIp"] = gatewayIP + } + subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet", + m.cloudProject, url.PathEscape(region), url.PathEscape(netID)) + var subnet map[string]interface{} + // Retry briefly to let the network activate + var subnetErr error + for i := 0; i < 10; i++ { + subnetErr = httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet) + if subnetErr == nil { + break + } + time.Sleep(2 * time.Second) + } + if subnetErr != nil { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais échec du sous-réseau (CIDR: %s): %w", m.wizard.privNetCIDR, subnetErr), + } + } + } + } else { + // Standard vRack regions use the legacy global private network API + body := map[string]interface{}{ + "name": m.wizard.privNetName, + "regions": []string{region}, + } + if m.wizard.privNetVlanID > 0 { + body["vlanId"] = m.wizard.privNetVlanID + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private", m.cloudProject) + if err := httpLib.Client.Post(endpoint, body, &network); err != nil { + return privNetCreatedMsg{err: fmt.Errorf("failed to create network: %w", err)} } - // Poll until the network region becomes ACTIVE (OVH creates async) - networkEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", - m.cloudProject, url.PathEscape(netID)) - const maxAttempts = 15 - regionActive := false - for i := 0; i < maxAttempts; i++ { - var netData map[string]interface{} - if err := httpLib.Client.Get(networkEndpoint, &netData); err == nil { - if regions, ok := netData["regions"].([]interface{}); ok { - for _, r := range regions { - if rMap, ok := r.(map[string]interface{}); ok { - if rMap["region"] == region { - if rMap["status"] == "ACTIVE" { - regionActive = true + // Optionally create a subnet + if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" { + netID, _ := network["id"].(string) + if netID == "" { + return privNetCreatedMsg{err: fmt.Errorf("réseau créé mais ID manquant, impossible de créer le sous-réseau")} + } + + // Poll until the network region becomes ACTIVE (OVH creates async) + networkEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", + m.cloudProject, url.PathEscape(netID)) + const maxAttempts = 15 + regionActive := false + for i := 0; i < maxAttempts; i++ { + var netData map[string]interface{} + if err := httpLib.Client.Get(networkEndpoint, &netData); err == nil { + if regions, ok := netData["regions"].([]interface{}); ok { + for _, r := range regions { + if rMap, ok := r.(map[string]interface{}); ok { + if rMap["region"] == region { + if rMap["status"] == "ACTIVE" { + regionActive = true + } } } } } } + if regionActive { + break + } + time.Sleep(3 * time.Second) } - if regionActive { - break - } - time.Sleep(3 * time.Second) - } - if !regionActive { - return privNetCreatedMsg{ - network: network, - err: fmt.Errorf("réseau créé mais la région '%s' n'est pas devenue active à temps — sous-réseau non créé. Réessayez depuis l'interface OVH.", region), + if !regionActive { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais la région '%s' n'est pas devenue active à temps — sous-réseau non créé. Réessayez depuis l'interface OVH.", region), + } } - } - noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will attach OVH Gateway service + noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will attach OVH Gateway service - // Always reserve network+1 for the gateway IP (whether static or OVH Gateway). - // Not reserving it causes a 409 conflict when the Gateway service tries to claim that IP. - startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, true) - if cidrErr != nil { - return privNetCreatedMsg{ - network: network, - err: fmt.Errorf("réseau créé mais CIDR invalide ('%s'): %w", m.wizard.privNetCIDR, cidrErr), + // Always reserve network+1 for the gateway IP (whether static or OVH Gateway). + // Not reserving it causes a 409 conflict when the Gateway service tries to claim that IP. + startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, true) + if cidrErr != nil { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais CIDR invalide ('%s'): %w", m.wizard.privNetCIDR, cidrErr), + } } - } - subnetBody := map[string]interface{}{ - "dhcp": m.wizard.privNetEnableDHCP, - "network": m.wizard.privNetCIDR, - "noGateway": noGateway, - "region": region, - "start": startIP, - "end": endIP, - } - var subnet map[string]interface{} - subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", - m.cloudProject, url.PathEscape(netID)) - if err := httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet); err != nil { - return privNetCreatedMsg{ - network: network, - err: fmt.Errorf("réseau créé mais échec du sous-réseau (%s, CIDR: %s): %w", netID, m.wizard.privNetCIDR, err), + subnetBody := map[string]interface{}{ + "dhcp": m.wizard.privNetEnableDHCP, + "network": m.wizard.privNetCIDR, + "noGateway": noGateway, + "region": region, + "start": startIP, + "end": endIP, + } + var subnet map[string]interface{} + subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", + m.cloudProject, url.PathEscape(netID)) + if err := httpLib.Client.Post(subnetEndpoint, subnetBody, &subnet); err != nil { + return privNetCreatedMsg{ + network: network, + err: fmt.Errorf("réseau créé mais échec du sous-réseau (%s, CIDR: %s): %w", netID, m.wizard.privNetCIDR, err), + } } } } @@ -216,6 +281,17 @@ func (m Model) renderPrivNetWizardVlanStep(width int) string { content.WriteString(titleStyle.Render("Option réseau layer 2 – VLAN ID :") + "\n\n") + // Show already-used VLAN IDs as a hint + if len(m.wizard.privNetUsedVlanIDs) > 0 { + var used []string + for id := range m.wizard.privNetUsedVlanIDs { + used = append(used, fmt.Sprintf("%d", id)) + } + sort.Strings(used) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")) + content.WriteString(warnStyle.Render(" VLAN déjà utilisés : "+strings.Join(used, ", ")) + "\n\n") + } + // Option 0 : auto if !m.wizard.privNetDefineVlan { content.WriteString(selectedStyle.Render("▶ Pas de VLAN (attribution automatique)") + "\n") @@ -437,6 +513,8 @@ func (m Model) handlePrivNetWizardRegionKeys(key string) (tea.Model, tea.Cmd) { if total > 0 { r := m.wizard.privNetRegions[m.wizard.privNetRegionIdx] m.wizard.selectedRegion, _ = r["name"].(string) + rtype, _ := r["type"].(string) + m.wizard.privNetIsLocalZone = (rtype == "localzone") m.wizard.step = PrivNetWizardStepName } } @@ -496,6 +574,10 @@ func (m Model) handlePrivNetWizardVlanKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) m.wizard.errorMsg = "VLAN ID invalide (1–4094)" return m, nil } + if m.wizard.privNetUsedVlanIDs[v] { + m.wizard.errorMsg = fmt.Sprintf("VLAN ID %d est déjà utilisé par un réseau existant", v) + return m, nil + } m.wizard.privNetVlanID = v } else { m.wizard.privNetVlanID = 0 // auto @@ -562,7 +644,11 @@ func (m Model) handlePrivNetWizardDHCPKeys(key string) (tea.Model, tea.Cmd) { m.wizard.privNetEnableDHCP = !m.wizard.privNetEnableDHCP } case "enter": - m.wizard.step = PrivNetWizardStepGateway + if m.wizard.privNetIsLocalZone { + m.wizard.step = PrivNetWizardStepConfirm + } else { + m.wizard.step = PrivNetWizardStepGateway + } case "left": m.wizard.step = PrivNetWizardStepSubnet } @@ -612,8 +698,12 @@ func (m Model) handlePrivNetWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { m.wizard.privNetConfirmBtnIdx = 1 case "enter": if m.wizard.privNetConfirmBtnIdx == 1 { - // Cancel button → go back to gateway step - m.wizard.step = PrivNetWizardStepGateway + // Cancel button → go back to previous step + if m.wizard.privNetIsLocalZone { + m.wizard.step = PrivNetWizardStepDHCP + } else { + m.wizard.step = PrivNetWizardStepGateway + } return m, nil } m.wizard.isLoading = true From 645a42c24e53ccd254f24fba72a7bca665c99b98 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Tue, 5 May 2026 14:04:28 +0000 Subject: [PATCH 39/55] feat(browser): added lb in network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 25 +- internal/services/browser/lb_wizard.go | 486 +++++++++++++++++++++++++ internal/services/browser/manager.go | 164 ++++++++- 3 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 internal/services/browser/lb_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 2fb6452a..7bcd3a84 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1614,6 +1614,26 @@ func (m Model) fetchLoadBalancersData() dataLoadedMsg { } } + // Build networkId -> name map by fetching private networks for each octavia region + allRegionNetworks, _ := httpLib.FetchObjectsParallel[[]map[string]any](regionEndpoint+"/%s/network", regions, true) + networkNameMap := make(map[string]string) // openstackId -> name + for _, nets := range allRegionNetworks { + for _, n := range nets { + if id, ok := n["id"].(string); ok { + if name, ok := n["name"].(string); ok && name != "" && name != "Ext-Net" { + networkNameMap[id] = name + } + } + } + } + for _, lb := range lbs { + if netID, ok := lb["vipNetworkId"].(string); ok && netID != "" { + if name, found := networkNameMap[netID]; found { + lb["_networkName"] = name + } + } + } + return dataLoadedMsg{data: lbs, err: nil} } @@ -2336,7 +2356,10 @@ func createLoadBalancersTable(data []map[string]interface{}, width, height int) if size == "" { size = getString(lb, "flavorId") } - privateNetwork := getString(lb, "vipNetworkId") + privateNetwork := getString(lb, "_networkName") + if privateNetwork == "" { + privateNetwork = getString(lb, "vipNetworkId") + } privateIP := getString(lb, "vipAddress") provisioning := getString(lb, "provisioningStatus") status := getString(lb, "operatingStatus") diff --git a/internal/services/browser/lb_wizard.go b/internal/services/browser/lb_wizard.go new file mode 100644 index 00000000..746df25f --- /dev/null +++ b/internal/services/browser/lb_wizard.go @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net/url" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// ─── API / fetch ────────────────────────────────────────────────────────────── + +// fetchLBRegions loads regions that support the Octavia load balancer service. +func (m Model) fetchLBRegions() tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + var allNames []string + if err := httpLib.Client.Get(endpoint, &allNames); err != nil { + return lbRegionsLoadedMsg{err: err} + } + ids := make([]any, len(allNames)) + for i, n := range allNames { + ids[i] = n + } + details, _ := httpLib.FetchObjectsParallel[map[string]any](endpoint+"/%s", ids, true) + var regions []string + for i, r := range details { + if r == nil { + continue + } + if services, ok := r["services"].([]interface{}); ok { + for _, svc := range services { + if sm, ok := svc.(map[string]interface{}); ok { + if sm["name"] == "octavialoadbalancer" && sm["status"] == "UP" { + regions = append(regions, allNames[i]) + break + } + } + } + } + } + sort.Strings(regions) + return lbRegionsLoadedMsg{regions: regions} + } +} + +// fetchLBFlavors loads available load balancer flavors (sizes) for the selected region. +func (m Model) fetchLBFlavors() tea.Cmd { + region := m.wizard.lbRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/flavor", + m.cloudProject, url.PathEscape(region)) + var flavors []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &flavors); err != nil { + return lbFlavorsLoadedMsg{err: err} + } + return lbFlavorsLoadedMsg{flavors: flavors} + } +} + +// fetchLBNetworks loads private networks available in the selected region. +func (m Model) fetchLBNetworks() tea.Cmd { + region := m.wizard.lbRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network", + m.cloudProject, url.PathEscape(region)) + var nets []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &nets); err != nil { + return lbNetworksLoadedMsg{err: err} + } + var filtered []map[string]interface{} + for _, n := range nets { + name := getStringValue(n, "name", "") + if name != "" && name != "Ext-Net" { + filtered = append(filtered, n) + } + } + return lbNetworksLoadedMsg{networks: filtered} + } +} + +// fetchLBSubnet fetches the first subnet of the chosen private network. +func (m Model) fetchLBSubnet(networkID string) tea.Cmd { + region := m.wizard.lbRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet", + m.cloudProject, url.PathEscape(region), url.PathEscape(networkID)) + var subnets []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &subnets); err != nil || len(subnets) == 0 { + return lbSubnetLoadedMsg{subnetID: ""} + } + return lbSubnetLoadedMsg{subnetID: getStringValue(subnets[0], "id", "")} + } +} + +// createLBFromWizard calls the OVHcloud API to create the load balancer. +// The API requires the network field with nested private.network.{id, subnetId}. +func (m Model) createLBFromWizard() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return lbCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + if m.wizard.lbNetworkId == "" || m.wizard.lbSubnetId == "" { + return lbCreatedMsg{err: fmt.Errorf("un réseau privé avec sous-réseau est obligatoire pour créer un load balancer")} + } + body := map[string]interface{}{ + "name": m.wizard.lbName, + "flavorId": m.wizard.lbFlavorId, + "network": map[string]interface{}{ + "private": map[string]interface{}{ + "network": map[string]interface{}{ + "id": m.wizard.lbNetworkId, + "subnetId": m.wizard.lbSubnetId, + }, + }, + }, + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/loadbalancer", + m.cloudProject, url.PathEscape(m.wizard.lbRegion)) + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return lbCreatedMsg{err: fmt.Errorf("échec de la création du load balancer: %w", err)} + } + return lbCreatedMsg{lb: result} + } +} + +// ─── Render functions ───────────────────────────────────────────────────────── + +func (m Model) renderLBWizardNameStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Nom du Load Balancer :") + "\n\n") + content.WriteString(descStyle.Render("Choisissez un nom unique pour identifier votre load balancer.") + "\n\n") + + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(40) + content.WriteString(inputStyle.Render(m.wizard.lbNameInput+"▌") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Tapez le nom • Enter : Continuer • Esc : Annuler")) + return content.String() +} + +func (m Model) renderLBWizardRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Choisir la région :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Nom : %s", m.wizard.lbName)) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des régions...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.lbAvailableRegions) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + Render("Aucune région compatible avec le Load Balancer (Octavia) trouvée.") + "\n") + } else { + maxVisible := 14 + startIdx := 0 + if m.wizard.lbRegionIdx >= maxVisible { + startIdx = m.wizard.lbRegionIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.wizard.lbAvailableRegions) { + endIdx = len(m.wizard.lbAvailableRegions) + } + for i := startIdx; i < endIdx; i++ { + r := m.wizard.lbAvailableRegions[i] + if i == m.wizard.lbRegionIdx { + content.WriteString(selectedStyle.Render("▶ "+r) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+r) + "\n") + } + } + if len(m.wizard.lbAvailableRegions) > maxVisible { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render(fmt.Sprintf("\n %d / %d régions", m.wizard.lbRegionIdx+1, len(m.wizard.lbAvailableRegions)))) + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderLBWizardFlavorStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Choisir la taille du Load Balancer :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Nom : %s • Région : %s", + m.wizard.lbName, m.wizard.lbRegion)) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des tailles disponibles...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.lbFlavors) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + Render("Aucune taille disponible dans cette région.") + "\n") + } else { + for i, f := range m.wizard.lbFlavors { + name := getStringValue(f, "name", getStringValue(f, "id", "unknown")) + if i == m.wizard.lbFlavorIdx { + content.WriteString(selectedStyle.Render("▶ "+strings.ToUpper(name)) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+strings.ToUpper(name)) + "\n") + } + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderLBWizardNetworkStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")) + + flavorDisplay := m.wizard.lbFlavorName + if flavorDisplay == "" { + flavorDisplay = m.wizard.lbFlavorId + } + + content.WriteString(titleStyle.Render("Sélectionner un réseau privé :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf( + "Nom : %s • Région : %s • Taille : %s", + m.wizard.lbName, m.wizard.lbRegion, strings.ToUpper(flavorDisplay), + )) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des réseaux...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.lbNetworks) == 0 { + content.WriteString(warnStyle.Render("⚠️ Aucun réseau privé disponible dans la région "+m.wizard.lbRegion+".") + "\n") + content.WriteString(descStyle.Render("Créez d'abord un réseau privé dans cette région, puis relancez le wizard.") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("← : Retour • Esc : Annuler")) + return content.String() + } + + for i, n := range m.wizard.lbNetworks { + name := getStringValue(n, "name", getStringValue(n, "id", "unknown")) + if i == m.wizard.lbNetworkIdx { + content.WriteString(selectedStyle.Render("▶ "+name) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+name) + "\n") + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderLBWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + lbLabelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(titleStyle.Render("Confirmer la création du Load Balancer :") + "\n\n") + content.WriteString(lbLabelStyle.Render(" Nom :") + valStyle.Render(m.wizard.lbName) + "\n") + content.WriteString(lbLabelStyle.Render(" Région :") + valStyle.Render(m.wizard.lbRegion) + "\n") + + flavorDisplay := m.wizard.lbFlavorName + if flavorDisplay == "" { + flavorDisplay = m.wizard.lbFlavorId + } + content.WriteString(lbLabelStyle.Render(" Taille :") + valStyle.Render(strings.ToUpper(flavorDisplay)) + "\n") + + if m.wizard.lbNetworkName != "" { + content.WriteString(lbLabelStyle.Render(" Réseau privé :") + valStyle.Render(m.wizard.lbNetworkName) + "\n") + } + content.WriteString("\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + if m.wizard.lbConfirmBtnIdx == 1 { + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + } + content.WriteString(btnCreate + " " + btnCancel + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler")) + return content.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handleLBWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "enter": + name := strings.TrimSpace(m.wizard.lbNameInput) + if name == "" { + m.wizard.errorMsg = "Le nom ne peut pas être vide" + return m, nil + } + m.wizard.lbName = name + m.wizard.errorMsg = "" + m.wizard.step = LBWizardStepRegion + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des régions..." + return m, m.fetchLBRegions() + case "backspace": + if len(m.wizard.lbNameInput) > 0 { + m.wizard.lbNameInput = m.wizard.lbNameInput[:len(m.wizard.lbNameInput)-1] + } + default: + if len(msg.Runes) > 0 { + m.wizard.lbNameInput += string(msg.Runes) + } + } + return m, nil +} + +func (m Model) handleLBWizardRegionKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.lbRegionIdx > 0 { + m.wizard.lbRegionIdx-- + } + case "down", "j": + if m.wizard.lbRegionIdx < len(m.wizard.lbAvailableRegions)-1 { + m.wizard.lbRegionIdx++ + } + case "enter": + if len(m.wizard.lbAvailableRegions) > 0 { + m.wizard.lbRegion = m.wizard.lbAvailableRegions[m.wizard.lbRegionIdx] + m.wizard.lbFlavorIdx = 0 + m.wizard.step = LBWizardStepFlavor + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des tailles..." + return m, m.fetchLBFlavors() + } + case "left": + m.wizard.step = LBWizardStepName + } + return m, nil +} + +func (m Model) handleLBWizardFlavorKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.lbFlavorIdx > 0 { + m.wizard.lbFlavorIdx-- + } + case "down", "j": + if m.wizard.lbFlavorIdx < len(m.wizard.lbFlavors)-1 { + m.wizard.lbFlavorIdx++ + } + case "enter": + if len(m.wizard.lbFlavors) > 0 { + f := m.wizard.lbFlavors[m.wizard.lbFlavorIdx] + m.wizard.lbFlavorId = getStringValue(f, "id", "") + m.wizard.lbFlavorName = getStringValue(f, "name", "") + m.wizard.lbNetworkIdx = 0 + m.wizard.step = LBWizardStepNetwork + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des réseaux..." + return m, m.fetchLBNetworks() + } + case "left": + m.wizard.step = LBWizardStepRegion + } + return m, nil +} + +func (m Model) handleLBWizardNetworkKeys(key string) (tea.Model, tea.Cmd) { + total := len(m.wizard.lbNetworks) + switch key { + case "up", "k": + if m.wizard.lbNetworkIdx > 0 { + m.wizard.lbNetworkIdx-- + } + case "down", "j": + if m.wizard.lbNetworkIdx < total-1 { + m.wizard.lbNetworkIdx++ + } + case "enter": + if total == 0 { + // No networks available — can't proceed + return m, nil + } + net := m.wizard.lbNetworks[m.wizard.lbNetworkIdx] + m.wizard.lbNetworkId = getStringValue(net, "id", "") + m.wizard.lbNetworkName = getStringValue(net, "name", getStringValue(net, "id", "unknown")) + // Try to find subnet ID embedded in network data + subnetID := "" + if subnets, ok := net["subnets"].([]interface{}); ok && len(subnets) > 0 { + switch v := subnets[0].(type) { + case string: + subnetID = v + case map[string]interface{}: + subnetID = getStringValue(v, "id", "") + } + } + if subnetID != "" { + m.wizard.lbSubnetId = subnetID + m.wizard.step = LBWizardStepConfirm + } else { + // Need to fetch subnet separately + m.wizard.isLoading = true + m.wizard.loadingMessage = "Vérification du sous-réseau..." + return m, m.fetchLBSubnet(m.wizard.lbNetworkId) + } + case "left": + m.wizard.step = LBWizardStepFlavor + } + return m, nil +} + +func (m Model) handleLBWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.lbConfirmBtnIdx = 0 + case "right", "l": + m.wizard.lbConfirmBtnIdx = 1 + case "enter": + if m.wizard.lbConfirmBtnIdx == 1 { + // Cancel → back to network selection + m.wizard.step = LBWizardStepNetwork + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Création du Load Balancer..." + return m, m.createLBFromWizard() + } + return m, nil +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 4287fe6b..5fdc1555 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -158,6 +158,15 @@ const ( GwWizardStepConfirm // confirm + create ) +const ( + // Load Balancer wizard steps (offset by 1000) + LBWizardStepName WizardStep = iota + 1000 // enter name + LBWizardStepRegion // select region + LBWizardStepFlavor // select size/flavor + LBWizardStepNetwork // select private network (optional) + LBWizardStepConfirm // confirm + create +) + // ProductType represents a product category type ProductType int @@ -402,6 +411,23 @@ type WizardData struct { // maps region name -> {"openstackId": "...", "subnetId": "..."} gwNetworkRegionMap map[string]map[string]string gwAttachMode bool // true when wizard was launched from private network detail view + + // Load Balancer wizard fields + lbName string + lbNameInput string + lbRegion string + lbRegionIdx int + lbAvailableRegions []string + lbFlavors []map[string]interface{} + lbFlavorIdx int + lbFlavorId string + lbFlavorName string + lbNetworks []map[string]interface{} + lbNetworkIdx int // 0 = Aucun réseau, 1+ = index into lbNetworks + lbNetworkId string + lbNetworkName string + lbSubnetId string + lbConfirmBtnIdx int } // Model represents the TUI application state @@ -879,6 +905,31 @@ type gwDeletedMsg struct { err error } +type lbCreatedMsg struct { + lb map[string]interface{} + err error +} + +type lbRegionsLoadedMsg struct { + regions []string + err error +} + +type lbFlavorsLoadedMsg struct { + flavors []map[string]interface{} + err error +} + +type lbNetworksLoadedMsg struct { + networks []map[string]interface{} + err error +} + +type lbSubnetLoadedMsg struct { + subnetID string + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1107,6 +1158,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Chargement des régions...", } return m, m.fetchGwRegions() + } else if msg.product == ProductNetworkLB { + m.mode = WizardView + m.wizard = WizardData{ + step: LBWizardStepName, + } + return m, nil } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1402,6 +1459,67 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.step = GwWizardStepConfirm return m, nil + case lbCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + lbName := getString(msg.lb, "name") + m.notification = fmt.Sprintf("✅ Load Balancer '%s' créé avec succès", lbName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/loadbalancer"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case lbRegionsLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.lbAvailableRegions = msg.regions + m.wizard.lbRegionIdx = 0 + return m, nil + + case lbFlavorsLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.lbFlavors = msg.flavors + m.wizard.lbFlavorIdx = 0 + return m, nil + + case lbNetworksLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.lbNetworks = msg.networks + m.wizard.lbNetworkIdx = 0 + return m, nil + + case lbSubnetLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.lbSubnetId = msg.subnetID + m.wizard.step = LBWizardStepConfirm + return m, nil + case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -2080,7 +2198,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 900 { + if m.wizard.step >= 1000 { + // Load Balancer wizard + titleText = " ⚖\ufe0f Create Load Balancer " + } else if m.wizard.step >= 900 { // Gateway wizard titleText = " 🌐 Create Gateway " } else if m.wizard.step >= 800 { @@ -3036,7 +3157,15 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 800 { + if m.wizard.step >= 1000 { + // Load Balancer wizard + steps = append(steps, "Nom", "Région", "Taille", "Réseau", "Confirmer") + stepMapping = append(stepMapping, LBWizardStepName, LBWizardStepRegion, LBWizardStepFlavor, LBWizardStepNetwork, LBWizardStepConfirm) + } else if m.wizard.step >= 900 { + // Gateway wizard + steps = append(steps, "Région", "Taille", "Nom", "Réseau", "Confirmer") + stepMapping = append(stepMapping, GwWizardStepRegion, GwWizardStepModel, GwWizardStepName, GwWizardStepNetwork, GwWizardStepConfirm) + } else if m.wizard.step >= 800 { // Private Network wizard steps = append(steps, "Région", "Nom", "VLAN", "Sous-réseau", "DHCP", "Passerelle", "Confirmer") stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) @@ -3250,6 +3379,17 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderGwWizardNetworkStep(width)) case GwWizardStepConfirm: content.WriteString(m.renderGwWizardConfirmStep(width)) + // Load Balancer wizard steps + case LBWizardStepName: + content.WriteString(m.renderLBWizardNameStep(width)) + case LBWizardStepRegion: + content.WriteString(m.renderLBWizardRegionStep(width)) + case LBWizardStepFlavor: + content.WriteString(m.renderLBWizardFlavorStep(width)) + case LBWizardStepNetwork: + content.WriteString(m.renderLBWizardNetworkStep(width)) + case LBWizardStepConfirm: + content.WriteString(m.renderLBWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: content.WriteString(m.renderBackupWizard(width)) @@ -6750,13 +6890,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // 'q' quits (except when typing in input fields) - if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "q" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != NodePoolWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && m.wizard.step != LBWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { return m, tea.Quit } // 'd' opens debug panel (except when typing in input fields) // Disable debug shortcut when: in name step, filter mode, creating SSH key, or creating network - if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { + if key == "d" && m.wizard.step != WizardStepName && m.wizard.step != KubeWizardStepName && m.wizard.step != VolumeWizardStepName && m.wizard.step != VolumeWizardStepSize && m.wizard.step != FileWizardStepName && m.wizard.step != FileWizardStepSize && m.wizard.step != ObjectWizardStepName && m.wizard.step != S3UserWizardStepDescription && m.wizard.step != BackupWizardStepName && m.wizard.step != PrivNetWizardStepName && m.wizard.step != PrivNetWizardStepVlanID && m.wizard.step != PrivNetWizardStepSubnet && m.wizard.step != PrivNetWizardStepGateway && m.wizard.step != GwWizardStepName && m.wizard.step != LBWizardStepName && !m.wizard.filterMode && !m.wizard.creatingSSHKey && !m.wizard.creatingNetwork { m.previousMode = m.mode m.mode = DebugView m.debugScrollOffset = 0 @@ -6774,7 +6914,10 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 900 { + if m.wizard.step >= 1000 { + // Load Balancer wizard: return to LB list + returnPath = "/loadbalancer" + } else if m.wizard.step >= 900 { // Gateway wizard: return to private network detail view m.wizard = WizardData{} m.mode = DetailView @@ -6967,6 +7110,17 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleGwWizardNetworkKeys(key) case GwWizardStepConfirm: return m.handleGwWizardConfirmKeys(key) + // Load Balancer wizard steps + case LBWizardStepName: + return m.handleLBWizardNameKeys(msg) + case LBWizardStepRegion: + return m.handleLBWizardRegionKeys(key) + case LBWizardStepFlavor: + return m.handleLBWizardFlavorKeys(key) + case LBWizardStepNetwork: + return m.handleLBWizardNetworkKeys(key) + case LBWizardStepConfirm: + return m.handleLBWizardConfirmKeys(key) } return m, nil } From 6de2b9e2dbde6d7566e478f9020a4fa5c6390a98 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 6 May 2026 07:32:23 +0000 Subject: [PATCH 40/55] feat(browser): added floating ip in public IPs Signed-off-by: olivier dubo --- internal/services/browser/api.go | 144 ++++++++- .../services/browser/floating_ip_wizard.go | 223 ++++++++++++++ internal/services/browser/manager.go | 288 +++++++++++++++++- 3 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 internal/services/browser/floating_ip_wizard.go diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 7bcd3a84..7d987ed4 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1298,6 +1298,30 @@ func (m Model) executeGatewayDelete() tea.Cmd { } } +// executeLBDelete deletes the currently selected load balancer. +func (m Model) executeLBDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return lbDeletedMsg{err: fmt.Errorf("aucun load balancer sélectionné")} + } + if m.cloudProject == "" { + return lbDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + lbID := getString(m.detailData, "id") + lbName := getString(m.detailData, "name") + region := getString(m.detailData, "region") + if lbID == "" || region == "" { + return lbDeletedMsg{err: fmt.Errorf("ID ou région du load balancer introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/loadbalancer/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(lbID)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return lbDeletedMsg{lbName: lbName, err: fmt.Errorf("échec de la suppression: %w", err)} + } + return lbDeletedMsg{lbName: lbName} + } +} + // executePrivNetworkDelete deletes the currently selected private network. func (m Model) executePrivNetworkDelete() tea.Cmd { return func() tea.Msg { @@ -1538,7 +1562,66 @@ func (m Model) fetchFloatingIPsData() dataLoadedMsg { } } - return dataLoadedMsg{data: floatingIPs, err: nil} + // Also fetch failover IPs (Additional IPs) + var failoverIPs []map[string]interface{} + failEndpoint := fmt.Sprintf("/v1/cloud/project/%s/ip/failover", m.cloudProject) + httpLib.Client.Get(failEndpoint, &failoverIPs) // ignore error — not all projects have failover IPs + + return dataLoadedMsg{data: floatingIPs, additionalIPs: failoverIPs, err: nil} +} + +// fetchFIPRegions loads network-capable regions for the floating IP wizard. +func (m Model) fetchFIPRegions() tea.Cmd { + return func() tea.Msg { + regions, err := m.fetchNetworkRegions() + if err != nil { + return fipRegionsLoadedMsg{err: err} + } + sort.Strings(regions) + return fipRegionsLoadedMsg{regions: regions} + } +} + +// fetchFIPInstances loads instances in the selected region for the floating IP wizard. +func (m Model) fetchFIPInstances() tea.Cmd { + region := m.wizard.fipRegion + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/instance?region=%s", + m.cloudProject, url.QueryEscape(region)) + var instances []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &instances); err != nil { + return fipInstancesLoadedMsg{err: err} + } + return fipInstancesLoadedMsg{instances: instances} + } +} + +// createStandaloneFloatingIP creates a floating IP and optionally attaches it to an instance. +func (m Model) createStandaloneFloatingIP() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return fipCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip", + m.cloudProject, url.PathEscape(m.wizard.fipRegion)) + body := map[string]interface{}{} + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return fipCreatedMsg{err: fmt.Errorf("échec de la création: %w", err)} + } + // Attach to instance if one was selected + if m.wizard.fipInstanceId != "" { + fipID := getString(result, "id") + if fipID != "" { + attachEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip/%s/attach", + m.cloudProject, url.PathEscape(m.wizard.fipRegion), url.PathEscape(fipID)) + httpLib.Client.Post(attachEndpoint, map[string]interface{}{ + "instanceId": m.wizard.fipInstanceId, + }, nil) + } + } + return fipCreatedMsg{floatingIP: result} + } } // fetchLoadBalancersData fetches load balancers from octavia-capable regions @@ -1882,6 +1965,9 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { m.mode = TableView return m, nil case ProductNetworkPublic: + m.currentData = msg.data + m.additionalIPsData = msg.additionalIPs + m.publicIPTabIdx = 0 m.table = createFloatingIPsTable(msg.data, m.width, m.height) case ProductNetworkGateway: m.table = createGatewaysTable(msg.data, m.width, m.height) @@ -2560,6 +2646,62 @@ func createFloatingIPsTable(data []map[string]interface{}, width, height int) ta return t } +// createAdditionalIPsTable creates a table for failover/additional IPs. +func createAdditionalIPsTable(data []map[string]interface{}, width, height int) table.Model { + columns := []table.Column{ + {Title: "IP Address", Width: 20}, + {Title: "Block", Width: 20}, + {Title: "Routed To (Instance)", Width: 36}, + {Title: "Geoloc", Width: 20}, + {Title: "Status", Width: 16}, + } + var rows []table.Row + for _, ip := range data { + addr := getString(ip, "ip") + if addr == "" { + addr = "-" + } + block := getString(ip, "block") + if block == "" { + block = "-" + } + routedTo := getString(ip, "routedTo") + if routedTo == "" { + routedTo = "-" + } + geoloc := getString(ip, "geoloc") + if geoloc == "" { + geoloc = getString(ip, "continentCode") + } + if geoloc == "" { + geoloc = "-" + } + status := getString(ip, "status") + if status == "" { + status = "-" + } + rows = append(rows, table.Row{addr, block, routedTo, geoloc, status}) + } + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(true) + s.Selected = s.Selected.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Bold(false) + t.SetStyles(s) + return t +} + // createBlockStorageTable creates a nicely formatted table for block storage volumes. func createBlockStorageTable(data []map[string]interface{}, width, height int) table.Model { columns := []table.Column{ diff --git a/internal/services/browser/floating_ip_wizard.go b/internal/services/browser/floating_ip_wizard.go new file mode 100644 index 00000000..ccce3800 --- /dev/null +++ b/internal/services/browser/floating_ip_wizard.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ─── Render functions ───────────────────────────────────────────────────────── + +func (m Model) renderFIPWizardRegionStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + content.WriteString(titleStyle.Render("Choisir la région pour la Floating IP :") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des régions...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.fipAvailableRegions) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + Render("Aucune région disponible.") + "\n") + } else { + maxVisible := 14 + startIdx := 0 + if m.wizard.fipRegionIdx >= maxVisible { + startIdx = m.wizard.fipRegionIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.wizard.fipAvailableRegions) { + endIdx = len(m.wizard.fipAvailableRegions) + } + for i := startIdx; i < endIdx; i++ { + r := m.wizard.fipAvailableRegions[i] + if i == m.wizard.fipRegionIdx { + content.WriteString(selectedStyle.Render("▶ "+r) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+r) + "\n") + } + } + if len(m.wizard.fipAvailableRegions) > maxVisible { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render(fmt.Sprintf("\n %d / %d régions", m.wizard.fipRegionIdx+1, len(m.wizard.fipAvailableRegions)))) + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler")) + return content.String() +} + +func (m Model) renderFIPWizardInstanceStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + content.WriteString(titleStyle.Render("Attacher à une instance (optionnel) :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Région : %s", m.wizard.fipRegion)) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Chargement des instances...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + // Index 0 = no instance (standalone floating IP) + entries := []string{"(Sans instance — IP standalone)"} + for _, inst := range m.wizard.fipInstances { + name := getStringValue(inst, "name", getStringValue(inst, "id", "unknown")) + entries = append(entries, name) + } + + maxVisible := 12 + startIdx := 0 + if m.wizard.fipInstanceIdx >= maxVisible { + startIdx = m.wizard.fipInstanceIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(entries) { + endIdx = len(entries) + } + for i := startIdx; i < endIdx; i++ { + if i == m.wizard.fipInstanceIdx { + content.WriteString(selectedStyle.Render("▶ "+entries[i]) + "\n") + } else { + content.WriteString(dimStyle.Render(" "+entries[i]) + "\n") + } + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return content.String() +} + +func (m Model) renderFIPWizardConfirmStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + lbLabelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + content.WriteString(titleStyle.Render("Confirmer la création de la Floating IP :") + "\n\n") + content.WriteString(lbLabelStyle.Render(" Région :") + valStyle.Render(m.wizard.fipRegion) + "\n") + + instanceDisplay := "Aucune (standalone)" + if m.wizard.fipInstanceName != "" { + instanceDisplay = m.wizard.fipInstanceName + } + content.WriteString(lbLabelStyle.Render(" Instance :") + valStyle.Render(instanceDisplay) + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + if m.wizard.fipConfirmBtnIdx == 1 { + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + } + content.WriteString(btnCreate + " " + btnCancel + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler")) + return content.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handleFIPWizardRegionKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.fipRegionIdx > 0 { + m.wizard.fipRegionIdx-- + } + case "down", "j": + if m.wizard.fipRegionIdx < len(m.wizard.fipAvailableRegions)-1 { + m.wizard.fipRegionIdx++ + } + case "enter": + if len(m.wizard.fipAvailableRegions) > 0 { + m.wizard.fipRegion = m.wizard.fipAvailableRegions[m.wizard.fipRegionIdx] + m.wizard.fipInstanceIdx = 0 + m.wizard.step = FIPWizardStepInstance + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des instances..." + return m, m.fetchFIPInstances() + } + } + return m, nil +} + +func (m Model) handleFIPWizardInstanceKeys(key string) (tea.Model, tea.Cmd) { + total := 1 + len(m.wizard.fipInstances) // standalone + instances + switch key { + case "up", "k": + if m.wizard.fipInstanceIdx > 0 { + m.wizard.fipInstanceIdx-- + } + case "down", "j": + if m.wizard.fipInstanceIdx < total-1 { + m.wizard.fipInstanceIdx++ + } + case "enter": + if m.wizard.fipInstanceIdx == 0 { + // Standalone — no instance + m.wizard.fipInstanceId = "" + m.wizard.fipInstanceName = "" + } else { + inst := m.wizard.fipInstances[m.wizard.fipInstanceIdx-1] + m.wizard.fipInstanceId = getStringValue(inst, "id", "") + m.wizard.fipInstanceName = getStringValue(inst, "name", getStringValue(inst, "id", "unknown")) + } + m.wizard.fipConfirmBtnIdx = 0 + m.wizard.step = FIPWizardStepConfirm + case "left": + m.wizard.step = FIPWizardStepRegion + } + return m, nil +} + +func (m Model) handleFIPWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.fipConfirmBtnIdx = 0 + case "right", "l": + m.wizard.fipConfirmBtnIdx = 1 + case "enter": + if m.wizard.fipConfirmBtnIdx == 1 { + // Cancel → back to instance selection + m.wizard.step = FIPWizardStepInstance + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Création de la Floating IP..." + return m, m.createStandaloneFloatingIP() + } + return m, nil +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 5fdc1555..16106038 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -167,6 +167,13 @@ const ( LBWizardStepConfirm // confirm + create ) +const ( + // Floating IP wizard steps (offset by 1100) + FIPWizardStepRegion WizardStep = iota + 1100 // select region + FIPWizardStepInstance // select instance (optional) + FIPWizardStepConfirm // confirm + create +) + // ProductType represents a product category type ProductType int @@ -428,6 +435,16 @@ type WizardData struct { lbNetworkName string lbSubnetId string lbConfirmBtnIdx int + + // Floating IP wizard fields + fipRegion string + fipRegionIdx int + fipAvailableRegions []string + fipInstances []map[string]interface{} + fipInstanceIdx int // 0 = standalone (no instance), 1+ = index into fipInstances + fipInstanceId string + fipInstanceName string + fipConfirmBtnIdx int } // Model represents the TUI application state @@ -496,6 +513,9 @@ type Model struct { // Private Networks tabs (0=Régions vRack, 1=Local Zones) privNetTabIdx int privNetLocalZones []map[string]interface{} + // Public IPs tabs (0=Floating IPs, 1=Additional IPs) + publicIPTabIdx int + additionalIPsData []map[string]interface{} // S3 user creation result (for credentials display) s3CreatedUser map[string]interface{} s3CreatedCredentials map[string]interface{} @@ -603,10 +623,11 @@ type instancesEnrichedMsg struct { } type dataLoadedMsg struct { - data []map[string]interface{} - err error - forProduct ProductType // The product that requested this data - s3Users []map[string]interface{} // S3 users (for Object Storage) + data []map[string]interface{} + err error + forProduct ProductType // The product that requested this data + s3Users []map[string]interface{} // S3 users (for Object Storage) + additionalIPs []map[string]interface{} // Failover IPs (for ProductNetworkPublic tab 1) } // setDefaultProjectMsg is returned after setting the default project @@ -930,6 +951,26 @@ type lbSubnetLoadedMsg struct { err error } +type lbDeletedMsg struct { + lbName string + err error +} + +type fipRegionsLoadedMsg struct { + regions []string + err error +} + +type fipInstancesLoadedMsg struct { + instances []map[string]interface{} + err error +} + +type fipCreatedMsg struct { + floatingIP map[string]interface{} + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1164,6 +1205,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { step: LBWizardStepName, } return m, nil + } else if msg.product == ProductNetworkPublic { + m.mode = WizardView + m.wizard = WizardData{ + step: FIPWizardStepRegion, + isLoading: true, + loadingMessage: "Chargement des régions...", + } + return m, m.fetchFIPRegions() } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1476,6 +1525,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case lbDeletedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Load Balancer '%s' supprimé avec succès", msg.lbName) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/loadbalancer"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case lbRegionsLoadedMsg: m.wizard.isLoading = false m.wizard.loadingMessage = "" @@ -1520,6 +1586,48 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.step = LBWizardStepConfirm return m, nil + case fipRegionsLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fipAvailableRegions = msg.regions + m.wizard.fipRegionIdx = 0 + return m, nil + + case fipInstancesLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.fipInstances = msg.instances + m.wizard.fipInstanceIdx = 0 + return m, nil + + case fipCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + ip := getString(msg.floatingIP, "ip") + if ip == "" { + ip = "en cours de provisioning" + } + m.notification = fmt.Sprintf("✅ Floating IP %s créée avec succès", ip) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/floatingip"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case volumeBackupCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -2198,7 +2306,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 1000 { + if m.wizard.step >= 1100 { + // Floating IP wizard + titleText = " 🌐 Create Floating IP " + } else if m.wizard.step >= 1000 { // Load Balancer wizard titleText = " ⚖\ufe0f Create Load Balancer " } else if m.wizard.step >= 900 { @@ -2301,6 +2412,10 @@ func (m Model) renderContentBox(width int) string { if m.currentProduct == ProductNetworkPrivate { contentStr = m.renderPrivateNetworksWithTabs(contentStr, width-6) } + // Add tabs for Public IPs + if m.currentProduct == ProductNetworkPublic { + contentStr = m.renderPublicIPsWithTabs(contentStr, width-6) + } case DetailView: contentStr = m.renderDetailView(width - 6) case NodePoolsView: @@ -3157,7 +3272,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 1000 { + if m.wizard.step >= 1100 { + // Floating IP wizard + steps = append(steps, "Région", "Instance", "Confirmer") + stepMapping = append(stepMapping, FIPWizardStepRegion, FIPWizardStepInstance, FIPWizardStepConfirm) + } else if m.wizard.step >= 1000 { // Load Balancer wizard steps = append(steps, "Nom", "Région", "Taille", "Réseau", "Confirmer") stepMapping = append(stepMapping, LBWizardStepName, LBWizardStepRegion, LBWizardStepFlavor, LBWizardStepNetwork, LBWizardStepConfirm) @@ -3390,6 +3509,13 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderLBWizardNetworkStep(width)) case LBWizardStepConfirm: content.WriteString(m.renderLBWizardConfirmStep(width)) + // Floating IP wizard steps + case FIPWizardStepRegion: + content.WriteString(m.renderFIPWizardRegionStep(width)) + case FIPWizardStepInstance: + content.WriteString(m.renderFIPWizardInstanceStep(width)) + case FIPWizardStepConfirm: + content.WriteString(m.renderFIPWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: content.WriteString(m.renderBackupWizard(width)) @@ -5081,6 +5207,28 @@ func (m Model) renderPrivateNetworksWithTabs(tableContent string, width int) str return content.String() } +func (m Model) renderPublicIPsWithTabs(tableContent string, width int) string { + var content strings.Builder + tabActiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 2) + tabInactiveStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#333333")). + Foreground(lipgloss.Color("#888888")).Padding(0, 2) + var t1, t2 string + if m.publicIPTabIdx == 0 { + t1 = tabActiveStyle.Render("Floating IPs") + t2 = tabInactiveStyle.Render("Additional IPs") + } else { + t1 = tabInactiveStyle.Render("Floating IPs") + t2 = tabActiveStyle.Render("Additional IPs") + } + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, t1, " ", t2) + "\n\n") + content.WriteString(tableContent) + return content.String() +} + func (m Model) renderDeleteConfirmView() string { var content strings.Builder var instanceName string @@ -5160,6 +5308,8 @@ func (m Model) renderDetailView(width int) string { return m.renderPrivateNetworkDetail(width) case ProductNetworkGateway: return m.renderGatewayDetail(width) + case ProductNetworkLB: + return m.renderLBDetail(width) case ProductStorageObject: if m.objectUserDetailView != nil { return m.objectUserDetailView.Render(width, 0) @@ -5431,6 +5581,91 @@ func (m Model) renderGatewayDetail(width int) string { return content.String() } +func (m Model) renderLBDetail(width int) string { + var content strings.Builder + + lbID := getStringValue(m.detailData, "id", "N/A") + lbName := getStringValue(m.detailData, "name", "Unknown") + region := getStringValue(m.detailData, "region", "N/A") + + size := getStringValue(m.detailData, "_flavorName", "") + if size == "" { + size = getStringValue(m.detailData, "flavorId", "N/A") + } + + provisioning := getStringValue(m.detailData, "provisioningStatus", "N/A") + operating := getStringValue(m.detailData, "operatingStatus", "N/A") + privateIP := getStringValue(m.detailData, "vipAddress", "N/A") + + privateNetwork := getStringValue(m.detailData, "_networkName", "") + if privateNetwork == "" { + privateNetwork = getStringValue(m.detailData, "vipNetworkId", "N/A") + } + + publicIP := "-" + if fi, ok := m.detailData["floatingIp"].(map[string]interface{}); ok { + if v := getStringValue(fi, "ip", ""); v != "" { + publicIP = v + } + } + + statusIcon := "🟢" + statusStyle := statusRunningStyle + if strings.ToLower(operating) != "online" && strings.ToLower(operating) != "no_monitor" { + statusIcon = "🟡" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + } + + labelSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := (width - 6) / 2 + if boxWidth < 35 { + boxWidth = 35 + } + + // Info box + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Operating Status"), statusStyle.Render(statusIcon+" "+operating))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Supply Status"), valueSt.Render(provisioning))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(lbID, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Région"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Taille"), valueSt.Render(strings.ToUpper(size)))) + infoBox := renderBox("Load Balancer "+lbName, infoContent.String(), boxWidth) + + // Network box + var netContent strings.Builder + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP publique"), valueSt.Render(publicIP))) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP privée (VIP)"), valueSt.Render(privateIP))) + netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Réseau privé"), valueSt.Render(truncate(privateNetwork, 36)))) + netBox := renderBox("Réseau", netContent.String(), boxWidth) + + // Actions + actions := []string{"Supprimer"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", netBox)) + return content.String() +} + func (m Model) renderPrivateNetworkDetail(width int) string { var content strings.Builder @@ -6011,6 +6246,15 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // Public IPs: ←/→ switches between Floating IPs and Additional IPs tabs + if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPublic && + (m.mode == TableView || m.mode == EmptyView) { + if m.publicIPTabIdx > 0 { + m.publicIPTabIdx = 0 + m.table = createFloatingIPsTable(m.currentData, m.width, m.height) + } + return m, nil + } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -6096,6 +6340,15 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // Public IPs: ←/→ switches between Floating IPs and Additional IPs tabs + if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPublic && + (m.mode == TableView || m.mode == EmptyView) { + if m.publicIPTabIdx < 1 { + m.publicIPTabIdx = 1 + m.table = createAdditionalIPsTable(m.additionalIPsData, m.width, m.height) + } + return m, nil + } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -6329,6 +6582,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true } return m, nil + } else if m.mode == DetailView && m.currentProduct == ProductNetworkLB { + switch m.selectedAction { + case 0: // Supprimer + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeLBDelete() + } + m.actionConfirm = true + } + return m, nil } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction { @@ -6914,7 +7177,10 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Determine which product we were on and return to it returnPath := "/instances" - if m.wizard.step >= 1000 { + if m.wizard.step >= 1100 { + // Floating IP wizard: return to public IPs list + returnPath = "/networks/floatingip" + } else if m.wizard.step >= 1000 { // Load Balancer wizard: return to LB list returnPath = "/loadbalancer" } else if m.wizard.step >= 900 { @@ -7121,6 +7387,13 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleLBWizardNetworkKeys(key) case LBWizardStepConfirm: return m.handleLBWizardConfirmKeys(key) + // Floating IP wizard steps + case FIPWizardStepRegion: + return m.handleFIPWizardRegionKeys(key) + case FIPWizardStepInstance: + return m.handleFIPWizardInstanceKeys(key) + case FIPWizardStepConfirm: + return m.handleFIPWizardConfirmKeys(key) } return m, nil } @@ -8507,6 +8780,7 @@ func (m Model) loadNetworkSubProduct() (Model, tea.Cmd) { m.detailData = nil m.currentData = nil m.inTableFocus = false + m.publicIPTabIdx = 0 if !sub.Enabled { m.mode = ComingSoonView From d04cc97dcebb28dc8c7990e313da3427d1519565 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 6 May 2026 08:40:24 +0000 Subject: [PATCH 41/55] feat(browser): fixed floating IPs with instance Signed-off-by: olivier dubo --- internal/services/browser/api.go | 55 ++++++++++++++----- .../services/browser/floating_ip_wizard.go | 4 +- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 7d987ed4..e62efa45 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1596,30 +1596,55 @@ func (m Model) fetchFIPInstances() tea.Cmd { } } -// createStandaloneFloatingIP creates a floating IP and optionally attaches it to an instance. +// createStandaloneFloatingIP creates a floating IP and attaches it to an instance. func (m Model) createStandaloneFloatingIP() tea.Cmd { return func() tea.Msg { if m.cloudProject == "" { return fipCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} } - endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip", - m.cloudProject, url.PathEscape(m.wizard.fipRegion)) - body := map[string]interface{}{} + if m.wizard.fipInstanceId == "" { + return fipCreatedMsg{err: fmt.Errorf("veuillez sélectionner une instance pour créer une Floating IP")} + } + + // Find the private IPv4 of the selected instance — required by the API. + var privateIP string + for _, inst := range m.wizard.fipInstances { + if id, _ := inst["id"].(string); id == m.wizard.fipInstanceId { + if addrs, ok := inst["ipAddresses"].([]interface{}); ok { + for _, a := range addrs { + addrMap, ok := a.(map[string]interface{}) + if !ok { + continue + } + ip, _ := addrMap["ip"].(string) + ipType, _ := addrMap["type"].(string) + version := fmt.Sprint(addrMap["version"]) + if ip != "" && version == "4" && ipType != "public" { + privateIP = ip + break + } + } + } + break + } + } + if privateIP == "" { + return fipCreatedMsg{err: fmt.Errorf("aucune adresse IP privée trouvée pour cette instance")} + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/instance/%s/floatingIp", + m.cloudProject, url.PathEscape(m.wizard.fipRegion), url.PathEscape(m.wizard.fipInstanceId)) + body := map[string]interface{}{ + "ip": privateIP, + "gateway": map[string]interface{}{ + "model": "s", + "name": "gw-" + m.wizard.fipInstanceName, + }, + } var result map[string]interface{} if err := httpLib.Client.Post(endpoint, body, &result); err != nil { return fipCreatedMsg{err: fmt.Errorf("échec de la création: %w", err)} } - // Attach to instance if one was selected - if m.wizard.fipInstanceId != "" { - fipID := getString(result, "id") - if fipID != "" { - attachEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip/%s/attach", - m.cloudProject, url.PathEscape(m.wizard.fipRegion), url.PathEscape(fipID)) - httpLib.Client.Post(attachEndpoint, map[string]interface{}{ - "instanceId": m.wizard.fipInstanceId, - }, nil) - } - } return fipCreatedMsg{floatingIP: result} } } diff --git a/internal/services/browser/floating_ip_wizard.go b/internal/services/browser/floating_ip_wizard.go index ccce3800..f99c8a4f 100644 --- a/internal/services/browser/floating_ip_wizard.go +++ b/internal/services/browser/floating_ip_wizard.go @@ -84,8 +84,8 @@ func (m Model) renderFIPWizardInstanceStep(width int) string { Render("Erreur : "+m.wizard.errorMsg) + "\n\n") } - // Index 0 = no instance (standalone floating IP) - entries := []string{"(Sans instance — IP standalone)"} + // Index 0 = no instance (not supported by OVH v1 API — shown as disabled hint) + entries := []string{"⚠ Sans instance (non supporté)"} for _, inst := range m.wizard.fipInstances { name := getStringValue(inst, "name", getStringValue(inst, "id", "unknown")) entries = append(entries, name) From e6c804af5d4cc9aed16969970502e8ee768b3707 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 6 May 2026 09:20:57 +0000 Subject: [PATCH 42/55] feat(browser): added details for floating ips Signed-off-by: olivier dubo --- internal/services/browser/api.go | 72 +++++- .../services/browser/floating_ip_wizard.go | 48 ++-- internal/services/browser/manager.go | 225 +++++++++++++++--- 3 files changed, 278 insertions(+), 67 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index e62efa45..daa91c98 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1634,21 +1634,77 @@ func (m Model) createStandaloneFloatingIP() tea.Cmd { endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/instance/%s/floatingIp", m.cloudProject, url.PathEscape(m.wizard.fipRegion), url.PathEscape(m.wizard.fipInstanceId)) - body := map[string]interface{}{ - "ip": privateIP, - "gateway": map[string]interface{}{ - "model": "s", - "name": "gw-" + m.wizard.fipInstanceName, - }, - } + + // First attempt: without gateway (subnet already has one) var result map[string]interface{} - if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + err := httpLib.Client.Post(endpoint, map[string]interface{}{"ip": privateIP}, &result) + if err != nil && strings.Contains(err.Error(), "subnet require to have router") { + // Subnet has no gateway yet — auto-create one and retry + bodyWithGW := map[string]interface{}{ + "ip": privateIP, + "gateway": map[string]interface{}{ + "model": "s", + "name": "gw-" + m.wizard.fipInstanceName, + }, + } + result = nil + err = httpLib.Client.Post(endpoint, bodyWithGW, &result) + } + if err != nil { return fipCreatedMsg{err: fmt.Errorf("échec de la création: %w", err)} } return fipCreatedMsg{floatingIP: result} } } +// executeFIPDelete deletes the currently selected floating IP via the cloud API. +func (m Model) executeFIPDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return fipDeletedMsg{err: fmt.Errorf("aucune Floating IP sélectionnée")} + } + if m.cloudProject == "" { + return fipDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + fipID := getString(m.detailData, "id") + fipIP := getString(m.detailData, "ip") + region := getString(m.detailData, "region") + if fipID == "" || region == "" { + return fipDeletedMsg{err: fmt.Errorf("ID ou région de la Floating IP introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(fipID)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil && !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "NotFound") { + return fipDeletedMsg{fipIP: fipIP, err: fmt.Errorf("échec de la suppression: %w", err)} + } + return fipDeletedMsg{fipIP: fipIP} + } +} + +// executeFIPDetach detaches the currently selected floating IP from its resource. +func (m Model) executeFIPDetach() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return fipDetachedMsg{err: fmt.Errorf("aucune Floating IP sélectionnée")} + } + if m.cloudProject == "" { + return fipDetachedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + fipID := getString(m.detailData, "id") + fipIP := getString(m.detailData, "ip") + region := getString(m.detailData, "region") + if fipID == "" || region == "" { + return fipDetachedMsg{err: fmt.Errorf("ID ou région de la Floating IP introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip/%s/detach", + m.cloudProject, url.PathEscape(region), url.PathEscape(fipID)) + if err := httpLib.Client.Post(endpoint, nil, nil); err != nil { + return fipDetachedMsg{fipIP: fipIP, err: fmt.Errorf("échec du détachement: %w", err)} + } + return fipDetachedMsg{fipIP: fipIP} + } +} + // fetchLoadBalancersData fetches load balancers from octavia-capable regions func (m Model) fetchLoadBalancersData() dataLoadedMsg { if m.cloudProject == "" { diff --git a/internal/services/browser/floating_ip_wizard.go b/internal/services/browser/floating_ip_wizard.go index f99c8a4f..c3fc5479 100644 --- a/internal/services/browser/floating_ip_wizard.go +++ b/internal/services/browser/floating_ip_wizard.go @@ -22,20 +22,20 @@ func (m Model) renderFIPWizardRegionStep(width int) string { selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) - content.WriteString(titleStyle.Render("Choisir la région pour la Floating IP :") + "\n\n") + content.WriteString(titleStyle.Render("Choose a region for the Floating IP:") + "\n\n") if m.wizard.isLoading { - content.WriteString(loadingStyle.Render("⏳ Chargement des régions...")) + content.WriteString(loadingStyle.Render("⏳ Loading regions...")) return content.String() } if m.wizard.errorMsg != "" { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). - Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + Render("Error: "+m.wizard.errorMsg) + "\n\n") } if len(m.wizard.fipAvailableRegions) == 0 { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). - Render("Aucune région disponible.") + "\n") + Render("No regions available.") + "\n") } else { maxVisible := 14 startIdx := 0 @@ -72,20 +72,20 @@ func (m Model) renderFIPWizardInstanceStep(width int) string { dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - content.WriteString(titleStyle.Render("Attacher à une instance (optionnel) :") + "\n\n") - content.WriteString(descStyle.Render(fmt.Sprintf("Région : %s", m.wizard.fipRegion)) + "\n\n") + content.WriteString(titleStyle.Render("Attach to an instance:") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Region: %s", m.wizard.fipRegion)) + "\n\n") if m.wizard.isLoading { - content.WriteString(loadingStyle.Render("⏳ Chargement des instances...")) + content.WriteString(loadingStyle.Render("⏳ Loading instances...")) return content.String() } if m.wizard.errorMsg != "" { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). - Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + Render("Error: "+m.wizard.errorMsg) + "\n\n") } - // Index 0 = no instance (not supported by OVH v1 API — shown as disabled hint) - entries := []string{"⚠ Sans instance (non supporté)"} + // Index 0 = not supported standalone (shown as disabled hint) + entries := []string{"⚠ No instance (not supported)"} for _, inst := range m.wizard.fipInstances { name := getStringValue(inst, "name", getStringValue(inst, "id", "unknown")) entries = append(entries, name) @@ -109,7 +109,7 @@ func (m Model) renderFIPWizardInstanceStep(width int) string { } content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("↑↓ Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + Render("↑↓ Navigate • Enter: Select • ←: Back • Esc: Cancel")) return content.String() } @@ -119,33 +119,33 @@ func (m Model) renderFIPWizardConfirmStep(width int) string { lbLabelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Confirmer la création de la Floating IP :") + "\n\n") - content.WriteString(lbLabelStyle.Render(" Région :") + valStyle.Render(m.wizard.fipRegion) + "\n") + content.WriteString(titleStyle.Render("Confirm Floating IP creation:") + "\n\n") + content.WriteString(lbLabelStyle.Render(" Region:") + valStyle.Render(m.wizard.fipRegion) + "\n") - instanceDisplay := "Aucune (standalone)" + instanceDisplay := "None (standalone)" if m.wizard.fipInstanceName != "" { instanceDisplay = m.wizard.fipInstanceName } - content.WriteString(lbLabelStyle.Render(" Instance :") + valStyle.Render(instanceDisplay) + "\n\n") + content.WriteString(lbLabelStyle.Render(" Instance:") + valStyle.Render(instanceDisplay) + "\n\n") if m.wizard.isLoading { - content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + content.WriteString(loadingStyle.Render("⏳ Creating...")) return content.String() } if m.wizard.errorMsg != "" { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). - Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + Render("Error: "+m.wizard.errorMsg) + "\n\n") } - btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") - btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Create ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Cancel ") if m.wizard.fipConfirmBtnIdx == 1 { - btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") - btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Create ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Cancel ") } content.WriteString(btnCreate + " " + btnCancel + "\n\n") content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler")) + Render("←→: Select • Enter: Confirm • Esc: Cancel")) return content.String() } @@ -167,7 +167,7 @@ func (m Model) handleFIPWizardRegionKeys(key string) (tea.Model, tea.Cmd) { m.wizard.fipInstanceIdx = 0 m.wizard.step = FIPWizardStepInstance m.wizard.isLoading = true - m.wizard.loadingMessage = "Chargement des instances..." + m.wizard.loadingMessage = "Loading instances..." return m, m.fetchFIPInstances() } } @@ -216,7 +216,7 @@ func (m Model) handleFIPWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { return m, nil } m.wizard.isLoading = true - m.wizard.loadingMessage = "Création de la Floating IP..." + m.wizard.loadingMessage = "Creating Floating IP..." return m, m.createStandaloneFloatingIP() } return m, nil diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 16106038..b74937c1 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -971,6 +971,16 @@ type fipCreatedMsg struct { err error } +type fipDeletedMsg struct { + fipIP string + err error +} + +type fipDetachedMsg struct { + fipIP string + err error +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1188,7 +1198,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { privNetCIDRInput: "10.0.0.0/16", privNetUsedVlanIDs: usedVlans, isLoading: true, - loadingMessage: "Chargement des régions...", + loadingMessage: "Loading regions...", } return m, m.fetchPrivateNetRegionsCmd() } else if msg.product == ProductNetworkGateway { @@ -1196,7 +1206,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard = WizardData{ step: GwWizardStepRegion, isLoading: true, - loadingMessage: "Chargement des régions...", + loadingMessage: "Loading regions...", } return m, m.fetchGwRegions() } else if msg.product == ProductNetworkLB { @@ -1210,7 +1220,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard = WizardData{ step: FIPWizardStepRegion, isLoading: true, - loadingMessage: "Chargement des régions...", + loadingMessage: "Loading regions...", } return m, m.fetchFIPRegions() } @@ -1437,7 +1447,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = TableView return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } - m.notification = fmt.Sprintf("✅ Gateway '%s' supprimée avec succès", msg.gatewayName) + m.notification = fmt.Sprintf("✅ Gateway '%s' deleted successfully", msg.gatewayName) m.notificationExpiry = time.Now().Add(5 * time.Second) m.detailData = nil m.mode = LoadingView @@ -1525,6 +1535,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case fipDeletedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Floating IP %s deleted successfully", msg.fipIP) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/floatingip"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + + case fipDetachedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Floating IP %s detached successfully", msg.fipIP) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/floatingip"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case lbDeletedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -3274,19 +3316,19 @@ func (m Model) renderWizardView(width int) string { // Build steps based on which wizard we're in (determine by first step >= 100) if m.wizard.step >= 1100 { // Floating IP wizard - steps = append(steps, "Région", "Instance", "Confirmer") + steps = append(steps, "Region", "Instance", "Confirm") stepMapping = append(stepMapping, FIPWizardStepRegion, FIPWizardStepInstance, FIPWizardStepConfirm) } else if m.wizard.step >= 1000 { // Load Balancer wizard - steps = append(steps, "Nom", "Région", "Taille", "Réseau", "Confirmer") + steps = append(steps, "Name", "Region", "Size", "Network", "Confirm") stepMapping = append(stepMapping, LBWizardStepName, LBWizardStepRegion, LBWizardStepFlavor, LBWizardStepNetwork, LBWizardStepConfirm) } else if m.wizard.step >= 900 { // Gateway wizard - steps = append(steps, "Région", "Taille", "Nom", "Réseau", "Confirmer") + steps = append(steps, "Region", "Size", "Name", "Network", "Confirm") stepMapping = append(stepMapping, GwWizardStepRegion, GwWizardStepModel, GwWizardStepName, GwWizardStepNetwork, GwWizardStepConfirm) } else if m.wizard.step >= 800 { // Private Network wizard - steps = append(steps, "Région", "Nom", "VLAN", "Sous-réseau", "DHCP", "Passerelle", "Confirmer") + steps = append(steps, "Region", "Name", "VLAN", "Subnet", "DHCP", "Gateway", "Confirm") stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) } else if m.wizard.step >= 700 { // Backup/Snapshot wizard @@ -5310,6 +5352,8 @@ func (m Model) renderDetailView(width int) string { return m.renderGatewayDetail(width) case ProductNetworkLB: return m.renderLBDetail(width) + case ProductNetworkPublic: + return m.renderFIPDetail(width) case ProductStorageObject: if m.objectUserDetailView != nil { return m.objectUserDetailView.Render(width, 0) @@ -5543,19 +5587,19 @@ func (m Model) renderGatewayDetail(width int) string { var infoContent strings.Builder infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Status"), statusStyle.Render(statusIcon+" "+status))) infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(gwID, 36)))) - infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Région"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Region"), valueSt.Render(region))) infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Model"), valueSt.Render(model))) infoBox := renderBox("Gateway "+gwName, infoContent.String(), boxWidth) // Network box var netContent strings.Builder - netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP publique"), valueSt.Render(publicIP))) - netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP privée"), valueSt.Render(truncate(privateIP, 36)))) - netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Réseau privé"), valueSt.Render(truncate(privateNetwork, 36)))) - netBox := renderBox("Réseau", netContent.String(), boxWidth) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Public IP"), valueSt.Render(publicIP))) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Private IP"), valueSt.Render(truncate(privateIP, 36)))) + netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Private Network"), valueSt.Render(truncate(privateNetwork, 36)))) + netBox := renderBox("Network", netContent.String(), boxWidth) // Actions - actions := []string{"Supprimer"} + actions := []string{"Delete"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -5572,9 +5616,9 @@ func (m Model) renderGatewayDetail(width int) string { if m.actionConfirm { actionsContent += "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFD700")).Bold(true). - Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) } - actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) content.WriteString(actionsBox + "\n\n") content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", netBox)) @@ -5628,19 +5672,19 @@ func (m Model) renderLBDetail(width int) string { infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Operating Status"), statusStyle.Render(statusIcon+" "+operating))) infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Supply Status"), valueSt.Render(provisioning))) infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(lbID, 36)))) - infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Région"), valueSt.Render(region))) - infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Taille"), valueSt.Render(strings.ToUpper(size)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Region"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Size"), valueSt.Render(strings.ToUpper(size)))) infoBox := renderBox("Load Balancer "+lbName, infoContent.String(), boxWidth) // Network box var netContent strings.Builder - netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP publique"), valueSt.Render(publicIP))) - netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP privée (VIP)"), valueSt.Render(privateIP))) - netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Réseau privé"), valueSt.Render(truncate(privateNetwork, 36)))) - netBox := renderBox("Réseau", netContent.String(), boxWidth) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Public IP"), valueSt.Render(publicIP))) + netContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Private IP (VIP)"), valueSt.Render(privateIP))) + netContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Private Network"), valueSt.Render(truncate(privateNetwork, 36)))) + netBox := renderBox("Network", netContent.String(), boxWidth) // Actions - actions := []string{"Supprimer"} + actions := []string{"Delete"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -5657,15 +5701,86 @@ func (m Model) renderLBDetail(width int) string { if m.actionConfirm { actionsContent += "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFD700")).Bold(true). - Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) } - actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) content.WriteString(actionsBox + "\n\n") content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", netBox)) return content.String() } +func (m Model) renderFIPDetail(width int) string { + var content strings.Builder + + fipID := getStringValue(m.detailData, "id", "N/A") + fipIP := getStringValue(m.detailData, "ip", "N/A") + region := getStringValue(m.detailData, "region", "N/A") + status := getStringValue(m.detailData, "status", "N/A") + + // Associated entity (instance or LB) + associatedTo := "-" + if entity, ok := m.detailData["associatedEntity"].(map[string]interface{}); ok { + entityType := getStringValue(entity, "type", "") + entityID := getStringValue(entity, "id", "") + if entityID != "" { + associatedTo = entityType + ": " + entityID + } + } + + labelSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := (width - 6) / 2 + if boxWidth < 35 { + boxWidth = 35 + } + + // Status styling + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + statusIcon := "🟢" + if strings.ToLower(status) != "active" { + statusIcon = "🟡" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + } + + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("IP Address"), valueSt.Render(fipIP))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(fipID, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Region"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Status"), statusStyle.Render(statusIcon+" "+status))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Attached to"), valueSt.Render(associatedTo))) + infoBox := renderBox("Floating IP "+fipIP, infoContent.String(), boxWidth) + + // Actions — "Detach" only shown when attached to something + actions := []string{"Delete"} + if associatedTo != "-" { + actions = append(actions, "Detach") + } + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(infoBox) + return content.String() +} + func (m Model) renderPrivateNetworkDetail(width int) string { var content strings.Builder @@ -5690,7 +5805,7 @@ func (m Model) renderPrivateNetworkDetail(width int) string { cidr = getStringValue(subnets[0], "cidr", "N/A") gatewayIP = getStringValue(subnets[0], "gatewayIp", "N/A") if dhcp, ok := subnets[0]["dhcpEnabled"].(bool); ok { - if dhcp { dhcpStr = "activé" } else { dhcpStr = "désactivé" } + if dhcp { dhcpStr = "enabled" } else { dhcpStr = "disabled" } } } @@ -5701,26 +5816,26 @@ func (m Model) renderPrivateNetworkDetail(width int) string { // Info box var infoContent strings.Builder infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("ID"), valueStyle.Render(truncate(netID, 36)))) - vlanStr := "automatique" + vlanStr := "automatic" if vlanID > 0 { vlanStr = fmt.Sprintf("%d", vlanID) } infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("VLAN ID"), valueStyle.Render(vlanStr))) - rTypeLabel := "Région (vRack)" + rTypeLabel := "Region (vRack)" if regionType == "localzone" { rTypeLabel = "Local Zone" } infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Type"), valueStyle.Render(rTypeLabel))) - infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Région"), valueStyle.Render(regionName))) - infoBox := renderBox("Réseau privé", infoContent.String(), boxWidth) + infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Region"), valueStyle.Render(regionName))) + infoBox := renderBox("Private Network", infoContent.String(), boxWidth) // Subnet box var subContent strings.Builder subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("CIDR"), valueStyle.Render(cidr))) - subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Passerelle"), valueStyle.Render(gatewayIP))) + subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Gateway"), valueStyle.Render(gatewayIP))) subContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("DHCP"), valueStyle.Render(dhcpStr))) - subBox := renderBox("Sous-réseau", subContent.String(), boxWidth) + subBox := renderBox("Subnet", subContent.String(), boxWidth) _ = netName // Actions - actions := []string{"Supprimer", "Assigner une Gateway"} + actions := []string{"Delete", "Assign Gateway"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -5737,9 +5852,9 @@ func (m Model) renderPrivateNetworkDetail(width int) string { if m.actionConfirm { actionsContent += "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFD700")).Bold(true). - Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Escape pour annuler", actions[m.selectedAction])) + Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) } - actionsBox := renderBox("Actions (←/→ pour naviguer, Enter pour exécuter)", actionsContent, width-4) + actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) content.WriteString(actionsBox + "\n\n") content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", subBox)) @@ -6237,6 +6352,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) + if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { + if m.selectedAction > 0 { + m.selectedAction-- + m.actionConfirm = false + } + return m, nil + } // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && (m.mode == TableView || m.mode == EmptyView) { @@ -6331,6 +6454,20 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) + if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { + fipAttached := false + if m.detailData != nil { + if entity, ok := m.detailData["associatedEntity"].(map[string]interface{}); ok { + fipAttached = getStringValue(entity, "id", "") != "" + } + } + if fipAttached && m.selectedAction < 1 { + m.selectedAction++ + m.actionConfirm = false + } + return m, nil + } // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && (m.mode == TableView || m.mode == EmptyView) { @@ -6592,6 +6729,22 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true } return m, nil + } else if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { + switch m.selectedAction { + case 0: // Supprimer + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeFIPDelete() + } + m.actionConfirm = true + case 1: // Détacher + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeFIPDetach() + } + m.actionConfirm = true + } + return m, nil } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction { @@ -6715,6 +6868,8 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailData = m.currentData[selectedRow] m.currentItemName = getStringValue(m.detailData, "name", "Item") m.mode = DetailView + m.selectedAction = 0 + m.actionConfirm = false if m.currentProduct == ProductStorageBlock { ctx := &views.Context{Width: m.width, Height: m.height} From d25c7fdd78a92190518006facc28b9600f0dfeae Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 6 May 2026 09:44:53 +0000 Subject: [PATCH 43/55] feat(browser): fix pirvate network with dhcp disabled and added ip adresse allocated Signed-off-by: olivier dubo --- internal/services/browser/api.go | 13 +- internal/services/browser/manager.go | 12 +- .../browser/private_network_wizard.go | 178 +++++++++++++----- 3 files changed, 150 insertions(+), 53 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index daa91c98..c826c9ce 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -2422,6 +2422,7 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int {Title: "CIDR", Width: 18}, {Title: "Gateway", Width: 16}, {Title: "DHCP", Width: 6}, + {Title: "IP address allocated", Width: 34}, } var rows []table.Row @@ -2455,6 +2456,7 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int cidr := "-" gateway := "-" dhcp := "-" + allocPool := "-" if subnets, ok := net["_subnets"].([]map[string]interface{}); ok && len(subnets) > 0 { sub := subnets[0] if v := getString(sub, "cidr"); v != "" { @@ -2470,8 +2472,17 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int dhcp = "✗" } } + if pools, ok := sub["ipPools"].([]interface{}); ok && len(pools) > 0 { + if pool, ok := pools[0].(map[string]interface{}); ok { + start := getString(pool, "start") + end := getString(pool, "end") + if start != "" && end != "" { + allocPool = start + " – " + end + } + } + } } - rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp}) + rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, allocPool}) } tableHeight := height - 15 diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index b74937c1..c032a159 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -146,6 +146,7 @@ const ( PrivNetWizardStepVlanID // VLAN ID (layer 2 option) PrivNetWizardStepSubnet // configure subnet CIDR PrivNetWizardStepDHCP // DHCP distribution options + PrivNetWizardStepAllocPool // IP allocation pool (start/end) PrivNetWizardStepGateway // gateway options PrivNetWizardStepConfirm // confirm ) @@ -394,6 +395,9 @@ type WizardData struct { privNetCIDR string // confirmed CIDR privNetEnableDHCP bool // DHCP distribution enabled privNetDHCPFieldIdx int // 0=toggle, 1=Next/Back + privNetAllocStart string // allocation pool start IP + privNetAllocEnd string // allocation pool end IP + privNetAllocField int // 0=start, 1=end privNetGatewayMode int // 0=announce first CIDR IP, 1=assign explicit IP privNetGatewayInput string // gateway IP input (mode 1) privNetGateway string // confirmed gateway IP (mode 1) @@ -3328,8 +3332,8 @@ func (m Model) renderWizardView(width int) string { stepMapping = append(stepMapping, GwWizardStepRegion, GwWizardStepModel, GwWizardStepName, GwWizardStepNetwork, GwWizardStepConfirm) } else if m.wizard.step >= 800 { // Private Network wizard - steps = append(steps, "Region", "Name", "VLAN", "Subnet", "DHCP", "Gateway", "Confirm") - stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) + steps = append(steps, "Region", "Name", "VLAN", "Subnet", "DHCP", "IP Pool", "Gateway", "Confirm") + stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepAllocPool, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) } else if m.wizard.step >= 700 { // Backup/Snapshot wizard steps = append(steps, "Volume", "Type", "Name", "Confirm") @@ -3525,6 +3529,8 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderPrivNetWizardSubnetStep(width)) case PrivNetWizardStepDHCP: content.WriteString(m.renderPrivNetWizardDHCPStep(width)) + case PrivNetWizardStepAllocPool: + content.WriteString(m.renderPrivNetWizardAllocPoolStep(width)) case PrivNetWizardStepGateway: content.WriteString(m.renderPrivNetWizardGatewayStep(width)) case PrivNetWizardStepConfirm: @@ -7516,6 +7522,8 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handlePrivNetWizardSubnetKeys(msg) case PrivNetWizardStepDHCP: return m.handlePrivNetWizardDHCPKeys(key) + case PrivNetWizardStepAllocPool: + return m.handlePrivNetWizardAllocPoolKeys(msg) case PrivNetWizardStepGateway: return m.handlePrivNetWizardGatewayKeys(msg) case PrivNetWizardStepConfirm: diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 2293738a..e303262b 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -157,23 +157,13 @@ func (m Model) createPrivateNetworkFromWizard() tea.Cmd { noGateway := m.wizard.privNetGatewayMode == 1 // mode 1 = will attach OVH Gateway service - // Always reserve network+1 for the gateway IP (whether static or OVH Gateway). - // Not reserving it causes a 409 conflict when the Gateway service tries to claim that IP. - startIP, endIP, cidrErr := cidrToFirstLast(m.wizard.privNetCIDR, true) - if cidrErr != nil { - return privNetCreatedMsg{ - network: network, - err: fmt.Errorf("réseau créé mais CIDR invalide ('%s'): %w", m.wizard.privNetCIDR, cidrErr), - } - } - subnetBody := map[string]interface{}{ "dhcp": m.wizard.privNetEnableDHCP, "network": m.wizard.privNetCIDR, "noGateway": noGateway, "region": region, - "start": startIP, - "end": endIP, + "start": m.wizard.privNetAllocStart, + "end": m.wizard.privNetAllocEnd, } var subnet map[string]interface{} subnetEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", @@ -374,25 +364,27 @@ func (m Model) renderPrivNetWizardSubnetStep(width int) string { func (m Model) renderPrivNetWizardDHCPStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Options de distribution des adresses DHCP :") + "\n\n") + content.WriteString(titleStyle.Render("DHCP configuration:") + "\n\n") - selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) + enabled := m.wizard.privNetEnableDHCP - dhcpLabel := "○ DHCP désactivé" - if m.wizard.privNetEnableDHCP { - dhcpLabel = "● DHCP activé ✓" - } - content.WriteString(selectedStyle.Render(dhcpLabel) + "\n\n") + enabledStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 1) - descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - if m.wizard.privNetEnableDHCP { - content.WriteString(descStyle.Render("Le DHCP distribuera automatiquement des adresses IP aux instances.") + "\n\n") + if enabled { + content.WriteString(enabledStyle.Render("▶ Enabled ✓") + "\n") + content.WriteString(dimStyle.Render(" Disabled") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")). + Render("IP addresses will be assigned automatically to instances.") + "\n\n") } else { - content.WriteString(descStyle.Render("Les adresses IP devront être configurées manuellement.") + "\n\n") + content.WriteString(dimStyle.Render(" Enabled") + "\n") + content.WriteString(enabledStyle.Render("▶ Disabled") + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")). + Render("IP addresses must be configured manually.") + "\n\n") } content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("Space/←→ : Basculer • Enter : Continuer • ← : Retour • Esc : Annuler")) + Render("↑↓/Space: Toggle • Enter: Continue • ←: Back • Esc: Cancel")) return content.String() } @@ -440,59 +432,63 @@ func (m Model) renderPrivNetWizardGatewayStep(width int) string { func (m Model) renderPrivNetWizardConfirmStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(26) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Confirmer la création du réseau privé :") + "\n\n") - content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.selectedRegion) + "\n") - content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.privNetName) + "\n") + content.WriteString(titleStyle.Render("Confirm private network creation:") + "\n\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.privNetName) + "\n") - vlanStr := "automatique" + vlanStr := "automatic" if m.wizard.privNetVlanID > 0 { vlanStr = fmt.Sprintf("%d", m.wizard.privNetVlanID) } - content.WriteString(labelStyle.Render(" VLAN ID :") + valueStyle.Render(vlanStr) + "\n") + content.WriteString(labelStyle.Render(" VLAN ID:") + valueStyle.Render(vlanStr) + "\n") if m.wizard.privNetEnableSubnet { - content.WriteString(labelStyle.Render(" Sous-réseau (CIDR) :") + valueStyle.Render(m.wizard.privNetCIDR) + "\n") - dhcpStr := "désactivé" + content.WriteString(labelStyle.Render(" Subnet (CIDR):") + valueStyle.Render(m.wizard.privNetCIDR) + "\n") + dhcpStr := "disabled" if m.wizard.privNetEnableDHCP { - dhcpStr = "activé" + dhcpStr = "enabled" + } + content.WriteString(labelStyle.Render(" DHCP:") + valueStyle.Render(dhcpStr) + "\n") + if m.wizard.privNetAllocStart != "" || m.wizard.privNetAllocEnd != "" { + allocStr := m.wizard.privNetAllocStart + " – " + m.wizard.privNetAllocEnd + content.WriteString(labelStyle.Render(" IP address allocated:") + valueStyle.Render(allocStr) + "\n") } - content.WriteString(labelStyle.Render(" DHCP :") + valueStyle.Render(dhcpStr) + "\n") var gwStr string if m.wizard.privNetGatewayMode == 0 { - gwStr = "Première IP du CIDR (auto)" + gwStr = "First IP of CIDR (auto)" } else { - gwStr = "IP assignée" + gwStr = "Assigned IP" if m.wizard.privNetGateway != "" { gwStr = m.wizard.privNetGateway } } - content.WriteString(labelStyle.Render(" Passerelle :") + valueStyle.Render(gwStr) + "\n") + content.WriteString(labelStyle.Render(" Gateway:") + valueStyle.Render(gwStr) + "\n") } else { - content.WriteString(labelStyle.Render(" Sous-réseau :") + valueStyle.Render("aucun") + "\n") + content.WriteString(labelStyle.Render(" Subnet:") + valueStyle.Render("none") + "\n") } content.WriteString("\n") if m.wizard.isLoading { - content.WriteString(loadingStyle.Render("⏳ Création en cours...")) + content.WriteString(loadingStyle.Render("⏳ Creating...")) return content.String() } if m.wizard.errorMsg != "" { - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Error: "+m.wizard.errorMsg) + "\n\n") } - btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") - btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Create ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Cancel ") if m.wizard.privNetConfirmBtnIdx == 1 { - btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") - btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Create ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Cancel ") } content.WriteString(btnCreate + " " + btnCancel + "\n\n") content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("←→ : Sélectionner • Enter : Confirmer • ← : Retour • Esc : Annuler")) + Render("←→: Select • Enter: Confirm • ←: Back • Esc: Cancel")) return content.String() } @@ -639,18 +635,101 @@ func (m Model) handlePrivNetWizardSubnetKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd func (m Model) handlePrivNetWizardDHCPKeys(key string) (tea.Model, tea.Cmd) { switch key { - case " ", "h", "l": + case " ", "up", "down", "k", "j": if m.wizard.privNetEnableSubnet { m.wizard.privNetEnableDHCP = !m.wizard.privNetEnableDHCP } case "enter": + // Pre-fill allocation pool from CIDR if not yet set + if m.wizard.privNetEnableSubnet && m.wizard.privNetCIDR != "" && m.wizard.privNetAllocStart == "" { + start, end, err := cidrToFirstLast(m.wizard.privNetCIDR, true) + if err == nil { + m.wizard.privNetAllocStart = start + m.wizard.privNetAllocEnd = end + } + } + m.wizard.privNetAllocField = 0 + m.wizard.step = PrivNetWizardStepAllocPool + case "left": + m.wizard.step = PrivNetWizardStepSubnet + } + return m, nil +} + +func (m Model) renderPrivNetWizardAllocPoolStep(width int) string { + var content strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(16) + activeStyle := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Padding(0, 1) + + content.WriteString(titleStyle.Render("IP address allocation pool:") + "\n\n") + if m.wizard.privNetCIDR != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("CIDR: "+m.wizard.privNetCIDR) + "\n\n") + } + + startStr := m.wizard.privNetAllocStart + endStr := m.wizard.privNetAllocEnd + if m.wizard.privNetAllocField == 0 { + content.WriteString(labelStyle.Render("Start IP:") + activeStyle.Render(startStr+"▌") + "\n") + content.WriteString(labelStyle.Render("End IP:") + dimStyle.Render(endStr) + "\n") + } else { + content.WriteString(labelStyle.Render("Start IP:") + dimStyle.Render(startStr) + "\n") + content.WriteString(labelStyle.Render("End IP:") + activeStyle.Render(endStr+"▌") + "\n") + } + + if m.wizard.errorMsg != "" { + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Error: "+m.wizard.errorMsg) + "\n") + } + + content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Tab/↑↓: Switch field • Enter: Continue • ←: Back • Esc: Cancel")) + return content.String() +} + +func (m Model) handlePrivNetWizardAllocPoolKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "tab", "down", "j": + m.wizard.privNetAllocField = 1 - m.wizard.privNetAllocField + case "up", "k": + m.wizard.privNetAllocField = 1 - m.wizard.privNetAllocField + case "enter": + // Basic validation + if net.ParseIP(m.wizard.privNetAllocStart) == nil { + m.wizard.errorMsg = "Invalid start IP: " + m.wizard.privNetAllocStart + return m, nil + } + if net.ParseIP(m.wizard.privNetAllocEnd) == nil { + m.wizard.errorMsg = "Invalid end IP: " + m.wizard.privNetAllocEnd + return m, nil + } + m.wizard.errorMsg = "" if m.wizard.privNetIsLocalZone { m.wizard.step = PrivNetWizardStepConfirm } else { m.wizard.step = PrivNetWizardStepGateway } case "left": - m.wizard.step = PrivNetWizardStepSubnet + m.wizard.errorMsg = "" + m.wizard.step = PrivNetWizardStepDHCP + case "backspace": + if m.wizard.privNetAllocField == 0 && len(m.wizard.privNetAllocStart) > 0 { + m.wizard.privNetAllocStart = m.wizard.privNetAllocStart[:len(m.wizard.privNetAllocStart)-1] + } else if m.wizard.privNetAllocField == 1 && len(m.wizard.privNetAllocEnd) > 0 { + m.wizard.privNetAllocEnd = m.wizard.privNetAllocEnd[:len(m.wizard.privNetAllocEnd)-1] + } + default: + if len(msg.Runes) > 0 { + ch := string(msg.Runes) + if m.wizard.privNetAllocField == 0 { + m.wizard.privNetAllocStart += ch + } else { + m.wizard.privNetAllocEnd += ch + } + } } return m, nil } @@ -677,8 +756,7 @@ func (m Model) handlePrivNetWizardGatewayKeys(msg tea.KeyMsg) (tea.Model, tea.Cm m.wizard.errorMsg = "" m.wizard.step = PrivNetWizardStepConfirm case "left": - m.wizard.step = PrivNetWizardStepDHCP - case "backspace": + m.wizard.step = PrivNetWizardStepAllocPool if m.wizard.privNetGatewayMode == 1 && len(m.wizard.privNetGatewayInput) > 0 { m.wizard.privNetGatewayInput = m.wizard.privNetGatewayInput[:len(m.wizard.privNetGatewayInput)-1] } @@ -700,7 +778,7 @@ func (m Model) handlePrivNetWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { if m.wizard.privNetConfirmBtnIdx == 1 { // Cancel button → go back to previous step if m.wizard.privNetIsLocalZone { - m.wizard.step = PrivNetWizardStepDHCP + m.wizard.step = PrivNetWizardStepAllocPool } else { m.wizard.step = PrivNetWizardStepGateway } From 7a87d24fb1be9544cd02118d085cb85ada81a827 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Wed, 6 May 2026 15:09:01 +0000 Subject: [PATCH 44/55] feat(browser): added subnet in private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 9 + internal/services/browser/manager.go | 218 ++++++++++++------ .../browser/private_network_wizard.go | 201 ++++++++++++---- 3 files changed, 313 insertions(+), 115 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index c826c9ce..6bf0dea8 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1658,6 +1658,15 @@ func (m Model) createStandaloneFloatingIP() tea.Cmd { } // executeFIPDelete deletes the currently selected floating IP via the cloud API. +func (m Model) fetchNetworkSubnets(networkID string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", m.cloudProject, networkID) + var subnets []map[string]any + _ = httpLib.Client.Get(endpoint, &subnets) + return subnetsLoadedMsg{networkID: networkID, subnets: subnets} + } +} + func (m Model) executeFIPDelete() tea.Cmd { return func() tea.Msg { if m.detailData == nil { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index c032a159..191d5051 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -403,7 +403,10 @@ type WizardData struct { privNetGateway string // confirmed gateway IP (mode 1) privNetConfirmBtnIdx int // 0=Create, 1=Cancel privNetIsLocalZone bool // true when selected region is a local zone - privNetUsedVlanIDs map[int]bool // VLAN IDs already in use (to validate before API call) + privNetUsedVlanIDs map[int]bool // VLAN IDs already in use (to validate before API call) + privNetAddSubnetMode bool // true when adding subnet to an existing network + privNetTargetNetworkID string // network ID to add subnet to (add-subnet mode) + privNetSubnettedRegions map[string]bool // regions that already have a subnet (add-subnet mode) // Gateway wizard fields gwNetworkID string @@ -900,6 +903,11 @@ type privNetCreatedMsg struct { err error } +type subnetAddedMsg struct { + networkID string + err error +} + type privNetDeletedMsg struct { networkName string err error @@ -985,6 +993,11 @@ type fipDetachedMsg struct { err error } +type subnetsLoadedMsg struct { + networkID string + subnets []map[string]any +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1402,10 +1415,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.errorMsg = msg.err.Error() return m, nil } - m.wizard.privNetRegions = msg.regions + // In add-subnet mode, filter out regions that already have a subnet + if m.wizard.privNetAddSubnetMode && len(m.wizard.privNetSubnettedRegions) > 0 { + var filtered []map[string]interface{} + for _, r := range msg.regions { + name, _ := r["name"].(string) + if !m.wizard.privNetSubnettedRegions[name] { + filtered = append(filtered, r) + } + } + m.wizard.privNetRegions = filtered + } else { + m.wizard.privNetRegions = msg.regions + } m.wizard.privNetRegionIdx = 0 return m, nil + case subnetAddedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Failed to add subnet: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + m.notification = "✅ Subnet added successfully" + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case privNetCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -1539,6 +1583,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case subnetsLoadedMsg: + if m.detailData != nil && getStringValue(m.detailData, "id", "") == msg.networkID { + m.detailData["_subnets"] = msg.subnets + } + return m, nil + case fipDeletedMsg: if msg.err != nil { m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) @@ -2458,10 +2508,6 @@ func (m Model) renderContentBox(width int) string { if m.currentProduct == ProductNetworkPrivate { contentStr = m.renderPrivateNetworksWithTabs(contentStr, width-6) } - // Add tabs for Public IPs - if m.currentProduct == ProductNetworkPublic { - contentStr = m.renderPublicIPsWithTabs(contentStr, width-6) - } case DetailView: contentStr = m.renderDetailView(width - 6) case NodePoolsView: @@ -3332,8 +3378,13 @@ func (m Model) renderWizardView(width int) string { stepMapping = append(stepMapping, GwWizardStepRegion, GwWizardStepModel, GwWizardStepName, GwWizardStepNetwork, GwWizardStepConfirm) } else if m.wizard.step >= 800 { // Private Network wizard - steps = append(steps, "Region", "Name", "VLAN", "Subnet", "DHCP", "IP Pool", "Gateway", "Confirm") - stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepAllocPool, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) + if m.wizard.privNetAddSubnetMode { + steps = append(steps, "Region", "Subnet", "DHCP", "IP Pool", "Gateway", "Confirm") + stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepAllocPool, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) + } else { + steps = append(steps, "Region", "Name", "VLAN", "Subnet", "DHCP", "IP Pool", "Gateway", "Confirm") + stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, PrivNetWizardStepAllocPool, PrivNetWizardStepGateway, PrivNetWizardStepConfirm) + } } else if m.wizard.step >= 700 { // Backup/Snapshot wizard steps = append(steps, "Volume", "Type", "Name", "Confirm") @@ -5146,7 +5197,7 @@ func (m Model) getProductCreationInfo() (string, string) { case ProductNetworks, ProductNetworkPrivate: return "private networks", fmt.Sprintf("ovhcloud cloud network private create --cloud-project %s", m.cloudProject) case ProductNetworkPublic: - return "public IPs", "" + return "floating IPs", "" case ProductNetworkGateway: return "gateways", "" case ProductNetworkLB: @@ -5256,25 +5307,7 @@ func (m Model) renderPrivateNetworksWithTabs(tableContent string, width int) str } func (m Model) renderPublicIPsWithTabs(tableContent string, width int) string { - var content strings.Builder - tabActiveStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#7B68EE")). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true).Padding(0, 2) - tabInactiveStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#333333")). - Foreground(lipgloss.Color("#888888")).Padding(0, 2) - var t1, t2 string - if m.publicIPTabIdx == 0 { - t1 = tabActiveStyle.Render("Floating IPs") - t2 = tabInactiveStyle.Render("Additional IPs") - } else { - t1 = tabInactiveStyle.Render("Floating IPs") - t2 = tabActiveStyle.Render("Additional IPs") - } - content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, t1, " ", t2) + "\n\n") - content.WriteString(tableContent) - return content.String() + return tableContent } func (m Model) renderDeleteConfirmView() string { @@ -5803,23 +5836,12 @@ func (m Model) renderPrivateNetworkDetail(width int) string { } } - // Subnets - cidr := "N/A" - gatewayIP := "N/A" - dhcpStr := "N/A" - if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok && len(subnets) > 0 { - cidr = getStringValue(subnets[0], "cidr", "N/A") - gatewayIP = getStringValue(subnets[0], "gatewayIp", "N/A") - if dhcp, ok := subnets[0]["dhcpEnabled"].(bool); ok { - if dhcp { dhcpStr = "enabled" } else { dhcpStr = "disabled" } - } - } - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) boxWidth := (width - 6) / 2 + fullWidth := width - 4 - // Info box + // Info box (top-left) var infoContent strings.Builder infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("ID"), valueStyle.Render(truncate(netID, 36)))) vlanStr := "automatic" @@ -5831,17 +5853,46 @@ func (m Model) renderPrivateNetworkDetail(width int) string { infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Region"), valueStyle.Render(regionName))) infoBox := renderBox("Private Network", infoContent.String(), boxWidth) - // Subnet box - var subContent strings.Builder - subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("CIDR"), valueStyle.Render(cidr))) - subContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Gateway"), valueStyle.Render(gatewayIP))) - subContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("DHCP"), valueStyle.Render(dhcpStr))) - subBox := renderBox("Subnet", subContent.String(), boxWidth) + // Subnets — full-width list below info row, one entry per subnet + subnets, _ := m.detailData["_subnets"].([]map[string]any) + var subnetBoxes []string + if len(subnets) == 0 { + subnetBoxes = append(subnetBoxes, renderBox("Subnets (0)", lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("No subnets"), fullWidth)) + } else { + for idx, sub := range subnets { + var sc strings.Builder + subID := getStringValue(sub, "id", "") + cidr := getStringValue(sub, "cidr", "N/A") + gatewayIP := getStringValue(sub, "gatewayIp", "N/A") + dhcpStr := "N/A" + if dhcp, ok := sub["dhcpEnabled"].(bool); ok { + if dhcp { dhcpStr = "enabled" } else { dhcpStr = "disabled" } + } + allocPool := "-" + if pools, ok := sub["ipPools"].([]interface{}); ok && len(pools) > 0 { + if pool, ok := pools[0].(map[string]interface{}); ok { + start := getString(pool, "start") + end := getString(pool, "end") + if start != "" && end != "" { + allocPool = start + " – " + end + } + } + } + if subID != "" { + sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("ID"), valueStyle.Render(truncate(subID, 36)))) + } + sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("CIDR"), valueStyle.Render(cidr))) + sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Gateway"), valueStyle.Render(gatewayIP))) + sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("DHCP"), valueStyle.Render(dhcpStr))) + sc.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IP allocated"), valueStyle.Render(allocPool))) + subnetBoxes = append(subnetBoxes, renderBox(fmt.Sprintf("Subnet %d/%d", idx+1, len(subnets)), sc.String(), fullWidth)) + } + } _ = netName // Actions - actions := []string{"Delete", "Assign Gateway"} + actions := []string{"Delete", "Assign Gateway", "Add Subnet"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -5863,7 +5914,10 @@ func (m Model) renderPrivateNetworkDetail(width int) string { actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) content.WriteString(actionsBox + "\n\n") - content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", subBox)) + content.WriteString(infoBox + "\n\n") + for _, sb := range subnetBoxes { + content.WriteString(sb + "\n") + } return content.String() } @@ -6375,15 +6429,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // Public IPs: ←/→ switches between Floating IPs and Additional IPs tabs - if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPublic && - (m.mode == TableView || m.mode == EmptyView) { - if m.publicIPTabIdx > 0 { - m.publicIPTabIdx = 0 - m.table = createFloatingIPsTable(m.currentData, m.width, m.height) - } - return m, nil - } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -6452,9 +6497,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway) + // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway, 2=Add Subnet) if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { - if m.selectedAction < 1 { + if m.selectedAction < 2 { m.selectedAction++ m.actionConfirm = false } @@ -6483,15 +6528,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // Public IPs: ←/→ switches between Floating IPs and Additional IPs tabs - if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPublic && - (m.mode == TableView || m.mode == EmptyView) { - if m.publicIPTabIdx < 1 { - m.publicIPTabIdx = 1 - m.table = createAdditionalIPsTable(m.additionalIPsData, m.width, m.height) - } - return m, nil - } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -6754,13 +6790,13 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction { - case 0: // Supprimer + case 0: // Delete if m.actionConfirm { m.actionConfirm = false return m, m.executePrivNetworkDelete() } m.actionConfirm = true - case 1: // Assigner une Gateway + case 1: // Assign Gateway m.actionConfirm = false // Build region list from the network's available regions var regionNames []string @@ -6833,6 +6869,38 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { gwAttachMode: true, } return m, nil + case 2: // Add Subnet + m.actionConfirm = false + netID := getStringValue(m.detailData, "id", "") + if netID == "" { + return m, nil + } + rType := getStringValue(m.detailData, "_regionType", "region") + // Build set of regions that already have a subnet + subnettedRegions := map[string]bool{} + if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok { + for _, s := range subnets { + if r := getStringValue(s, "region", ""); r != "" { + subnettedRegions[r] = true + } + } + } + m.mode = WizardView + m.wizard = WizardData{ + step: PrivNetWizardStepRegion, + privNetAddSubnetMode: true, + privNetTargetNetworkID: netID, + privNetName: getStringValue(m.detailData, "name", ""), + privNetIsLocalZone: rType == "localzone", + privNetEnableDHCP: true, + privNetEnableSubnet: true, + privNetGatewayMode: 0, + privNetCIDRInput: "10.0.0.0/16", + privNetSubnettedRegions: subnettedRegions, + isLoading: true, + loadingMessage: "Loading regions...", + } + return m, m.fetchPrivateNetRegionsCmd() } return m, nil } else if m.mode == ProjectSelectView { @@ -6867,7 +6935,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Open detail view from table (replaces former 'v' key) if m.mode == TableView { - isSubNavProd := (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB) + isSubNavProd := (m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductNetworkLB) if !isSubNavProd || m.inTableFocus { selectedRow := m.table.Cursor() if selectedRow >= 0 && selectedRow < len(m.currentData) { @@ -6918,6 +6986,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.fetchKubeNodePools(kubeId) } } + if m.currentProduct == ProductNetworkPrivate { + netId := getStringValue(m.detailData, "id", "") + if netId != "" { + return m, m.fetchNetworkSubnets(netId) + } + } } } } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index e303262b..5541cbd0 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -319,45 +319,70 @@ func (m Model) renderPrivNetWizardSubnetStep(width int) string { var content strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - - content.WriteString(titleStyle.Render("Configurer le sous-réseau :") + "\n\n") - - // Toggle: enable/disable subnet - selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + + if m.wizard.privNetAddSubnetMode { + content.WriteString(titleStyle.Render("Subnet CIDR for new region:") + "\n\n") + + // Show existing subnets so user knows what CIDRs are already taken + if subnets, ok := m.detailData["_subnets"].([]map[string]any); ok && len(subnets) > 0 { + content.WriteString(warnStyle.Render("⚠ Existing subnets (use a different CIDR):") + "\n") + for _, sub := range subnets { + cidr := getStringValue(sub, "cidr", "") + region := getStringValue(sub, "region", "") + if cidr != "" { + content.WriteString(dimStyle.Render(fmt.Sprintf(" • %s (%s)", cidr, region)) + "\n") + } + } + content.WriteString("\n") + } + } else { + content.WriteString(titleStyle.Render("Configure subnet:") + "\n\n") - enableLabel := "○ Créer un sous-réseau" - if m.wizard.privNetEnableSubnet { - enableLabel = "● Créer un sous-réseau ✓" - } - content.WriteString(selectedStyle.Render(enableLabel) + "\n\n") - - if m.wizard.privNetEnableSubnet { - // Build example CIDR: 10.{vlanId}.0.0/16, fallback to 10.0.0.0/16 - cidrExample := "10.0.0.0/16" - if m.wizard.privNetVlanID > 0 { - cidrExample = fmt.Sprintf("10.%d.0.0/16", m.wizard.privNetVlanID) + // Toggle: enable/disable subnet + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")) + enableLabel := "○ Create a subnet" + if m.wizard.privNetEnableSubnet { + enableLabel = "● Create a subnet ✓" } - content.WriteString(descStyle.Render("CIDR du sous-réseau (ex : "+cidrExample+") :") + "\n") - inputStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00FF7F")). - Padding(0, 1).Width(30) - cidr := m.wizard.privNetCIDRInput - if cidr == "" { - cidr = "(vide)" + content.WriteString(selectedStyle.Render(enableLabel) + "\n\n") + if !m.wizard.privNetEnableSubnet { + content.WriteString(dimStyle.Render(" No subnet will be created.") + "\n\n") + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Space: Enable/Disable • Enter: Continue • ←: Back • Esc: Cancel")) + return content.String() } - content.WriteString(inputStyle.Render(cidr+"▌") + "\n\n") - } else { - content.WriteString(dimStyle.Render(" Aucun sous-réseau ne sera créé.") + "\n\n") } + // CIDR input + cidrExample := "10.0.0.0/16" + if m.wizard.privNetVlanID > 0 { + cidrExample = fmt.Sprintf("10.%d.0.0/16", m.wizard.privNetVlanID) + } + content.WriteString(descStyle.Render("Subnet CIDR (e.g. "+cidrExample+"):") + "\n") + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(0, 1).Width(30) + cidr := m.wizard.privNetCIDRInput + if cidr == "" { + cidr = "(empty)" + } + content.WriteString(inputStyle.Render(cidr+"▌") + "\n\n") + if m.wizard.errorMsg != "" { - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Error: "+m.wizard.errorMsg) + "\n\n") } - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). - Render("Space : Activer/Désactiver • Enter : Continuer • ← : Retour • Esc : Annuler")) + hint := "Space: Enable/Disable • Enter: Continue • ←: Back • Esc: Cancel" + if m.wizard.privNetAddSubnetMode { + hint = "Enter: Continue • ←: Back • Esc: Cancel" + } + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(hint)) return content.String() } @@ -435,17 +460,21 @@ func (m Model) renderPrivNetWizardConfirmStep(width int) string { labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(26) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(titleStyle.Render("Confirm private network creation:") + "\n\n") - content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") - content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.privNetName) + "\n") - - vlanStr := "automatic" - if m.wizard.privNetVlanID > 0 { - vlanStr = fmt.Sprintf("%d", m.wizard.privNetVlanID) + if m.wizard.privNetAddSubnetMode { + content.WriteString(titleStyle.Render("Confirm adding subnet to: "+m.wizard.privNetName) + "\n\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") + } else { + content.WriteString(titleStyle.Render("Confirm private network creation:") + "\n\n") + content.WriteString(labelStyle.Render(" Region:") + valueStyle.Render(m.wizard.selectedRegion) + "\n") + content.WriteString(labelStyle.Render(" Name:") + valueStyle.Render(m.wizard.privNetName) + "\n") + vlanStr := "automatic" + if m.wizard.privNetVlanID > 0 { + vlanStr = fmt.Sprintf("%d", m.wizard.privNetVlanID) + } + content.WriteString(labelStyle.Render(" VLAN ID:") + valueStyle.Render(vlanStr) + "\n") } - content.WriteString(labelStyle.Render(" VLAN ID:") + valueStyle.Render(vlanStr) + "\n") - if m.wizard.privNetEnableSubnet { + if m.wizard.privNetEnableSubnet || m.wizard.privNetAddSubnetMode { content.WriteString(labelStyle.Render(" Subnet (CIDR):") + valueStyle.Render(m.wizard.privNetCIDR) + "\n") dhcpStr := "disabled" if m.wizard.privNetEnableDHCP { @@ -480,10 +509,14 @@ func (m Model) renderPrivNetWizardConfirmStep(width int) string { content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Error: "+m.wizard.errorMsg) + "\n\n") } - btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Create ") + confirmLabel := " Create " + if m.wizard.privNetAddSubnetMode { + confirmLabel = " Add Subnet " + } + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(confirmLabel) btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Cancel ") if m.wizard.privNetConfirmBtnIdx == 1 { - btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Create ") + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(confirmLabel) btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Cancel ") } content.WriteString(btnCreate + " " + btnCancel + "\n\n") @@ -511,7 +544,11 @@ func (m Model) handlePrivNetWizardRegionKeys(key string) (tea.Model, tea.Cmd) { m.wizard.selectedRegion, _ = r["name"].(string) rtype, _ := r["type"].(string) m.wizard.privNetIsLocalZone = (rtype == "localzone") - m.wizard.step = PrivNetWizardStepName + if m.wizard.privNetAddSubnetMode { + m.wizard.step = PrivNetWizardStepSubnet + } else { + m.wizard.step = PrivNetWizardStepName + } } } return m, nil @@ -620,7 +657,11 @@ func (m Model) handlePrivNetWizardSubnetKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd m.wizard.errorMsg = "" m.wizard.step = PrivNetWizardStepDHCP case "left": - m.wizard.step = PrivNetWizardStepVlanID + if m.wizard.privNetAddSubnetMode { + m.wizard.step = PrivNetWizardStepRegion + } else { + m.wizard.step = PrivNetWizardStepVlanID + } case "backspace": if m.wizard.privNetEnableSubnet && len(m.wizard.privNetCIDRInput) > 0 { m.wizard.privNetCIDRInput = m.wizard.privNetCIDRInput[:len(m.wizard.privNetCIDRInput)-1] @@ -785,6 +826,10 @@ func (m Model) handlePrivNetWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { return m, nil } m.wizard.isLoading = true + if m.wizard.privNetAddSubnetMode { + m.wizard.loadingMessage = "Adding subnet..." + return m, m.createSubnetForNetwork() + } m.wizard.loadingMessage = "Création du réseau privé..." return m, m.createPrivateNetworkFromWizard() } @@ -816,3 +861,73 @@ func cidrToFirstLast(cidr string, reserveGateway bool) (first, last string, err lastIP := net.IP{broadcast[0], broadcast[1], broadcast[2], broadcast[3] - 1} return firstIP.String(), lastIP.String(), nil } + +func (m Model) createSubnetForNetwork() tea.Cmd { + return func() tea.Msg { + netID := m.wizard.privNetTargetNetworkID + region := m.wizard.selectedRegion + + // Check if region is already activated on the network; if not, activate it first + networkEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", m.cloudProject, url.PathEscape(netID)) + var netData map[string]interface{} + regionActive := false + if err := httpLib.Client.Get(networkEndpoint, &netData); err == nil { + if regions, ok := netData["regions"].([]interface{}); ok { + for _, rv := range regions { + if rm, ok := rv.(map[string]interface{}); ok { + if rm["region"] == region { + regionActive = true + } + } + } + } + } + if !regionActive { + // Activate the region on the network first + activateEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/region", m.cloudProject, url.PathEscape(netID)) + var op map[string]interface{} + if err := httpLib.Client.Post(activateEndpoint, map[string]interface{}{"region": region}, &op); err != nil { + return subnetAddedMsg{networkID: netID, err: fmt.Errorf("failed to activate region %s on network: %w", region, err)} + } + // Poll until ACTIVE + for i := 0; i < 20; i++ { + time.Sleep(3 * time.Second) + var nd map[string]interface{} + if err := httpLib.Client.Get(networkEndpoint, &nd); err == nil { + if regs, ok := nd["regions"].([]interface{}); ok { + for _, rv := range regs { + if rm, ok := rv.(map[string]interface{}); ok { + if rm["region"] == region && rm["status"] == "ACTIVE" { + regionActive = true + } + } + } + } + } + if regionActive { + break + } + } + if !regionActive { + return subnetAddedMsg{networkID: netID, err: fmt.Errorf("region %s did not become ACTIVE in time", region)} + } + } + + noGateway := m.wizard.privNetGatewayMode == 1 + subnetBody := map[string]interface{}{ + "dhcp": m.wizard.privNetEnableDHCP, + "network": m.wizard.privNetCIDR, + "noGateway": noGateway, + "region": region, + "start": m.wizard.privNetAllocStart, + "end": m.wizard.privNetAllocEnd, + } + var subnet map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", + m.cloudProject, url.PathEscape(netID)) + if err := httpLib.Client.Post(endpoint, subnetBody, &subnet); err != nil { + return subnetAddedMsg{networkID: netID, err: err} + } + return subnetAddedMsg{networkID: netID} + } +} From d6ab299c785b45ec737fb576f2d2c1a68626fc74 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 09:37:58 +0000 Subject: [PATCH 45/55] feat(browser): fixed subnet in private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 119 +++++++++++ internal/services/browser/manager.go | 189 ++++++++++++++++-- .../browser/private_network_wizard.go | 37 +++- 3 files changed, 317 insertions(+), 28 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 6bf0dea8..d8fd0818 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1667,6 +1667,125 @@ func (m Model) fetchNetworkSubnets(networkID string) tea.Cmd { } } +func (m Model) fetchPrivateNetworkDetail(networkID string) tea.Cmd { + return func() tea.Msg { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", m.cloudProject, url.PathEscape(networkID)) + var netData map[string]interface{} + if err := httpLib.Client.Get(endpoint, &netData); err != nil { + return privNetDetailLoadedMsg{networkID: networkID} + } + regions, _ := netData["regions"].([]interface{}) + return privNetDetailLoadedMsg{networkID: networkID, regions: regions} + } +} + +func (m Model) executeRegionDelete() tea.Cmd { + return func() tea.Msg { + netID := getStringValue(m.detailData, "id", "") + if netID == "" { + return regionDeletedMsg{err: fmt.Errorf("network ID missing")} + } + regions, _ := m.detailData["regions"].([]interface{}) + if m.privNetSelectedRegion >= len(regions) { + return regionDeletedMsg{networkID: netID, err: fmt.Errorf("no region selected")} + } + rm, _ := regions[m.privNetSelectedRegion].(map[string]interface{}) + regionName := getString(rm, "region") + if regionName == "" { + return regionDeletedMsg{networkID: netID, err: fmt.Errorf("region name missing")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/region/%s", + m.cloudProject, url.PathEscape(netID), url.PathEscape(regionName)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "409") || strings.Contains(errMsg, "ports") || strings.Contains(errMsg, "Conflict") { + return regionDeletedMsg{networkID: netID, region: regionName, err: fmt.Errorf("cannot remove region %s: resources still attached (instances, subnets)", regionName)} + } + return regionDeletedMsg{networkID: netID, region: regionName, err: fmt.Errorf("failed to remove region: %w", err)} + } + return regionDeletedMsg{networkID: netID, region: regionName} + } +} + +func (m Model) executeSubnetDelete() tea.Cmd { + return func() tea.Msg { + subnets, _ := m.detailData["_subnets"].([]map[string]any) + if m.privNetSelectedSubnet >= len(subnets) { + return subnetDeletedMsg{err: fmt.Errorf("no subnet selected")} + } + sub := subnets[m.privNetSelectedSubnet] + subID := getStringValue(sub, "id", "") + netID := getStringValue(m.detailData, "id", "") + openstackNetID := getStringValue(sub, "networkId", "") + if subID == "" || netID == "" { + return subnetDeletedMsg{networkID: netID, err: fmt.Errorf("subnet or network ID missing")} + } + + // Resolve region by matching openstackId from the network's regions list. + // Fast path: subnet has networkId (returned by new regional endpoint). + // Fallback: query each region's subnet list to find where this subnet lives. + region := "" + if regions, ok := m.detailData["regions"].([]interface{}); ok { + for _, rv := range regions { + if rm, ok := rv.(map[string]interface{}); ok { + if getString(rm, "openstackId") == openstackNetID { + region = getString(rm, "region") + break + } + } + } + if region == "" { + for _, rv := range regions { + if rm, ok := rv.(map[string]interface{}); ok { + rName := getString(rm, "region") + opID := getString(rm, "openstackId") + if rName == "" || opID == "" { + continue + } + searchEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet", + m.cloudProject, url.PathEscape(rName), url.PathEscape(opID)) + var regionSubnets []map[string]interface{} + if err := httpLib.Client.Get(searchEndpoint, ®ionSubnets); err == nil { + for _, rs := range regionSubnets { + if getString(rs, "id") == subID { + region = rName + openstackNetID = opID + break + } + } + } + if region != "" { + break + } + } + } + } + } + if region == "" { + return subnetDeletedMsg{networkID: netID, err: fmt.Errorf("could not determine region for subnet")} + } + + // DELETE /v1/cloud/project/{id}/region/{region}/network/{openstackNetId}/subnet/{subnetId} + deleteEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(openstackNetID), url.PathEscape(subID)) + + if err := httpLib.Client.Delete(deleteEndpoint, nil); err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "400") || strings.Contains(errMsg, "Unable to complete operation") || + strings.Contains(errMsg, "ports") || strings.Contains(errMsg, "409") { + gatewayIp := getStringValue(sub, "gatewayIp", "") + hint := "Remove attached instances, gateways, or router interfaces first" + if gatewayIp != "" && gatewayIp != "N/A" { + hint = fmt.Sprintf("A gateway is attached (IP: %s). Delete the associated OVH Gateway from Network > Gateway first", gatewayIp) + } + return subnetDeletedMsg{networkID: netID, err: fmt.Errorf("cannot delete subnet: %s", hint)} + } + return subnetDeletedMsg{networkID: netID, err: fmt.Errorf("failed to delete subnet: %w", err)} + } + return subnetDeletedMsg{networkID: netID} + } +} + func (m Model) executeFIPDelete() tea.Cmd { return func() tea.Msg { if m.detailData == nil { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 191d5051..6928ece3 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -518,8 +518,10 @@ type Model struct { objectStorageTabIdx int objectStorageUsers []map[string]interface{} // Private Networks tabs (0=Régions vRack, 1=Local Zones) - privNetTabIdx int - privNetLocalZones []map[string]interface{} + privNetTabIdx int + privNetLocalZones []map[string]interface{} + privNetSelectedSubnet int // index of subnet selected for deletion in detail view + privNetSelectedRegion int // index of region selected for deletion in detail view // Public IPs tabs (0=Floating IPs, 1=Additional IPs) publicIPTabIdx int additionalIPsData []map[string]interface{} @@ -908,6 +910,17 @@ type subnetAddedMsg struct { err error } +type subnetDeletedMsg struct { + networkID string + err error +} + +type regionDeletedMsg struct { + networkID string + region string + err error +} + type privNetDeletedMsg struct { networkName string err error @@ -998,6 +1011,11 @@ type subnetsLoadedMsg struct { subnets []map[string]any } +type privNetDetailLoadedMsg struct { + networkID string + regions []interface{} +} + func getNavItems() []NavItem { return []NavItem{ {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, @@ -1431,6 +1449,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.privNetRegionIdx = 0 return m, nil + case subnetDeletedMsg: + m.actionConfirm = false + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Failed to delete subnet: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = "✅ Subnet deleted successfully" + m.notificationExpiry = time.Now().Add(5 * time.Second) + // Refresh subnets in the current detail view + if m.detailData != nil { + return m, tea.Batch( + m.fetchNetworkSubnets(msg.networkID), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + + case regionDeletedMsg: + m.actionConfirm = false + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Failed to delete region: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Region %s removed from network", msg.region) + m.notificationExpiry = time.Now().Add(5 * time.Second) + // Reload the private networks list to reflect the change + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/networks/private"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case subnetAddedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -1589,6 +1641,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case privNetDetailLoadedMsg: + if m.detailData != nil && getStringValue(m.detailData, "id", "") == msg.networkID { + m.detailData["regions"] = msg.regions + } + return m, nil + case fipDeletedMsg: if msg.err != nil { m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) @@ -5828,20 +5886,13 @@ func (m Model) renderPrivateNetworkDetail(width int) string { vlanID := int(getFloatValue(m.detailData, "vlanId", 0)) regionType := getStringValue(m.detailData, "_regionType", "region") - // First region name - regionName := "N/A" - if regions, ok := m.detailData["regions"].([]interface{}); ok && len(regions) > 0 { - if rm, ok := regions[0].(map[string]interface{}); ok { - regionName = getStringValue(rm, "region", "N/A") - } - } - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(18) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) boxWidth := (width - 6) / 2 fullWidth := width - 4 // Info box (top-left) + rawRegions, _ := m.detailData["regions"].([]interface{}) var infoContent strings.Builder infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("ID"), valueStyle.Render(truncate(netID, 36)))) vlanStr := "automatic" @@ -5850,7 +5901,33 @@ func (m Model) renderPrivateNetworkDetail(width int) string { rTypeLabel := "Region (vRack)" if regionType == "localzone" { rTypeLabel = "Local Zone" } infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Type"), valueStyle.Render(rTypeLabel))) - infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Region"), valueStyle.Render(regionName))) + // Regions list + if len(rawRegions) == 0 { + infoContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("Regions"), lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("none"))) + } else { + for idx, rv := range rawRegions { + rm, _ := rv.(map[string]interface{}) + rName := getStringValue(rm, "region", "N/A") + rStatus := getStringValue(rm, "status", "") + label := "Regions" + if idx > 0 { label = "" } + line := rName + if rStatus != "" { line += " (" + rStatus + ")" } + // Highlight selected region when Delete Region action is active + if m.selectedAction == 4 && idx == m.privNetSelectedRegion { + arrow := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true).Render(" ◄") + line = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true).Render(line) + arrow + if m.actionConfirm { + line += lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true).Render(" ⚠️ Enter to confirm, Esc to cancel") + } + } else { + line = valueStyle.Render(line) + } + sep := "\n" + if idx == len(rawRegions)-1 { sep = "" } + infoContent.WriteString(fmt.Sprintf("%s %s%s", labelStyle.Render(label), line, sep)) + } + } infoBox := renderBox("Private Network", infoContent.String(), boxWidth) // Subnets — full-width list below info row, one entry per subnet @@ -5885,14 +5962,23 @@ func (m Model) renderPrivateNetworkDetail(width int) string { sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Gateway"), valueStyle.Render(gatewayIP))) sc.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("DHCP"), valueStyle.Render(dhcpStr))) sc.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IP allocated"), valueStyle.Render(allocPool))) - subnetBoxes = append(subnetBoxes, renderBox(fmt.Sprintf("Subnet %d/%d", idx+1, len(subnets)), sc.String(), fullWidth)) + boxTitle := fmt.Sprintf("Subnet %d/%d", idx+1, len(subnets)) + // Highlight selected subnet when Delete Subnet action is active + if m.selectedAction == 3 && idx == m.privNetSelectedSubnet { + boxTitle += " ◄ selected for deletion" + if m.actionConfirm { + sc.WriteString("\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render("⚠️ Press Enter to confirm deletion, Esc to cancel")) + } + } + subnetBoxes = append(subnetBoxes, renderBox(boxTitle, sc.String(), fullWidth)) } } _ = netName // Actions - actions := []string{"Delete", "Assign Gateway", "Add Subnet"} + actions := []string{"Delete", "Assign Gateway", "Add Subnet", "Delete Subnet", "Delete Region"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -5906,12 +5992,18 @@ func (m Model) renderPrivateNetworkDetail(width int) string { } } actionsContent := strings.Join(actionParts, " ") - if m.actionConfirm { + if m.actionConfirm && m.selectedAction != 3 && m.selectedAction != 4 { actionsContent += "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFD700")).Bold(true). Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) } - actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) + hintAction := "←/→ to navigate, Enter to execute" + if m.selectedAction == 3 { + hintAction = "←/→ to navigate • ↑/↓ to select subnet • Enter to delete" + } else if m.selectedAction == 4 { + hintAction = "←/→ to navigate • ↑/↓ to select region • Enter to delete" + } + actionsBox := renderBox("Actions ("+hintAction+")", actionsContent, width-4) content.WriteString(actionsBox + "\n\n") content.WriteString(infoBox + "\n\n") @@ -6497,9 +6589,9 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway, 2=Add Subnet) + // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway, 2=Add Subnet, 3=Delete Subnet) if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { - if m.selectedAction < 2 { + if m.selectedAction < 4 { m.selectedAction++ m.actionConfirm = false } @@ -6901,6 +6993,32 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { loadingMessage: "Loading regions...", } return m, m.fetchPrivateNetRegionsCmd() + case 3: // Delete Subnet + subnets, _ := m.detailData["_subnets"].([]map[string]any) + if len(subnets) == 0 { + return m, nil + } + if m.privNetSelectedSubnet >= len(subnets) { + m.privNetSelectedSubnet = 0 + } + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeSubnetDelete() + } + m.actionConfirm = true + case 4: // Delete Region + regions, _ := m.detailData["regions"].([]interface{}) + if len(regions) == 0 { + return m, nil + } + if m.privNetSelectedRegion >= len(regions) { + m.privNetSelectedRegion = 0 + } + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeRegionDelete() + } + m.actionConfirm = true } return m, nil } else if m.mode == ProjectSelectView { @@ -6989,7 +7107,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.currentProduct == ProductNetworkPrivate { netId := getStringValue(m.detailData, "id", "") if netId != "" { - return m, m.fetchNetworkSubnets(netId) + return m, tea.Batch( + m.fetchNetworkSubnets(netId), + m.fetchPrivateNetworkDetail(netId), + ) } } } @@ -7056,6 +7177,38 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // Private network detail: ↑/↓ to select subnet when Delete Subnet action is active + if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate && m.selectedAction == 3 { + subnets, _ := m.detailData["_subnets"].([]map[string]any) + if len(subnets) > 0 { + if key == "down" || key == "j" { + if m.privNetSelectedSubnet < len(subnets)-1 { + m.privNetSelectedSubnet++ + } + } else if key == "up" || key == "k" { + if m.privNetSelectedSubnet > 0 { + m.privNetSelectedSubnet-- + } + } + } + return m, nil + } + // Private network detail: ↑/↓ to select region when Delete Region action is active + if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate && m.selectedAction == 4 { + regions, _ := m.detailData["regions"].([]interface{}) + if len(regions) > 0 { + if key == "down" || key == "j" { + if m.privNetSelectedRegion < len(regions)-1 { + m.privNetSelectedRegion++ + } + } else if key == "up" || key == "k" { + if m.privNetSelectedRegion > 0 { + m.privNetSelectedRegion-- + } + } + } + return m, nil + } // Table navigation: for sub-nav products requires Level 3 (inTableFocus); non-sub-nav always allowed if m.mode == TableView || m.mode == ProjectSelectView { if !isSubNavProduct || m.inTableFocus { diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 5541cbd0..5d918b34 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -871,12 +871,14 @@ func (m Model) createSubnetForNetwork() tea.Cmd { networkEndpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s", m.cloudProject, url.PathEscape(netID)) var netData map[string]interface{} regionActive := false + openstackID := "" if err := httpLib.Client.Get(networkEndpoint, &netData); err == nil { if regions, ok := netData["regions"].([]interface{}); ok { for _, rv := range regions { if rm, ok := rv.(map[string]interface{}); ok { if rm["region"] == region { regionActive = true + openstackID, _ = rm["openstackId"].(string) } } } @@ -889,7 +891,7 @@ func (m Model) createSubnetForNetwork() tea.Cmd { if err := httpLib.Client.Post(activateEndpoint, map[string]interface{}{"region": region}, &op); err != nil { return subnetAddedMsg{networkID: netID, err: fmt.Errorf("failed to activate region %s on network: %w", region, err)} } - // Poll until ACTIVE + // Poll until ACTIVE and capture the openstackId for i := 0; i < 20; i++ { time.Sleep(3 * time.Second) var nd map[string]interface{} @@ -899,6 +901,7 @@ func (m Model) createSubnetForNetwork() tea.Cmd { if rm, ok := rv.(map[string]interface{}); ok { if rm["region"] == region && rm["status"] == "ACTIVE" { regionActive = true + openstackID, _ = rm["openstackId"].(string) } } } @@ -913,18 +916,32 @@ func (m Model) createSubnetForNetwork() tea.Cmd { } } - noGateway := m.wizard.privNetGatewayMode == 1 + if openstackID == "" { + return subnetAddedMsg{networkID: netID, err: fmt.Errorf("could not find OpenStack network ID for region %s", region)} + } + + // Detect IP version from CIDR + ipVersion := 4 + if strings.Contains(m.wizard.privNetCIDR, ":") { + ipVersion = 6 + } + + enableGateway := m.wizard.privNetGatewayMode != 1 // mode 1 = noGateway + subnetBody := map[string]interface{}{ - "dhcp": m.wizard.privNetEnableDHCP, - "network": m.wizard.privNetCIDR, - "noGateway": noGateway, - "region": region, - "start": m.wizard.privNetAllocStart, - "end": m.wizard.privNetAllocEnd, + "cidr": m.wizard.privNetCIDR, + "enableDhcp": m.wizard.privNetEnableDHCP, + "enableGatewayIp": enableGateway, + "ipVersion": ipVersion, + "name": fmt.Sprintf("%s-%s", m.wizard.privNetName, region), + "allocationPools": []map[string]interface{}{ + {"start": m.wizard.privNetAllocStart, "end": m.wizard.privNetAllocEnd}, + }, } + var subnet map[string]interface{} - endpoint := fmt.Sprintf("/v1/cloud/project/%s/network/private/%s/subnet", - m.cloudProject, url.PathEscape(netID)) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s/subnet", + m.cloudProject, url.PathEscape(region), url.PathEscape(openstackID)) if err := httpLib.Client.Post(endpoint, subnetBody, &subnet); err != nil { return subnetAddedMsg{networkID: netID, err: err} } From edced617651dc72092cb9ab3577e6432ec34d9a9 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 09:56:52 +0000 Subject: [PATCH 46/55] feat(browser): added detach gateway on private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 71 +++++++++++++++++++++----- internal/services/browser/manager.go | 76 ++++++++++++---------------- 2 files changed, 89 insertions(+), 58 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index d8fd0818..6876b025 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1707,6 +1707,62 @@ func (m Model) executeRegionDelete() tea.Cmd { } } +// executeGatewayDetachFromNetwork finds all gateway interfaces attached to this private network +// (matched by OpenStack network ID) across all regions, and deletes them. +func (m Model) executeGatewayDetachFromNetwork() tea.Cmd { + return func() tea.Msg { + netID := getStringValue(m.detailData, "id", "") + if netID == "" { + return gatewayDetachedMsg{err: fmt.Errorf("network ID missing")} + } + regions, _ := m.detailData["regions"].([]interface{}) + if len(regions) == 0 { + return gatewayDetachedMsg{networkID: netID, err: fmt.Errorf("no regions found on this network")} + } + + detached := 0 + for _, rv := range regions { + rm, _ := rv.(map[string]interface{}) + regionName := getString(rm, "region") + openstackID := getString(rm, "openstackId") + if regionName == "" || openstackID == "" { + continue + } + gwEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/gateway", m.cloudProject, url.PathEscape(regionName)) + var gateways []map[string]interface{} + if err := httpLib.Client.Get(gwEndpoint, &gateways); err != nil { + continue + } + for _, gw := range gateways { + gwID := getString(gw, "id") + if gwID == "" { + continue + } + interfaces, _ := gw["interfaces"].([]interface{}) + for _, iface := range interfaces { + ifaceMap, _ := iface.(map[string]interface{}) + if getString(ifaceMap, "networkId") != openstackID { + continue + } + ifaceID := getString(ifaceMap, "id") + if ifaceID == "" { + continue + } + ifaceEndpoint := fmt.Sprintf("%s/%s/interface/%s", + gwEndpoint, url.PathEscape(gwID), url.PathEscape(ifaceID)) + if err := httpLib.Client.Delete(ifaceEndpoint, nil); err == nil { + detached++ + } + } + } + } + if detached == 0 { + return gatewayDetachedMsg{networkID: netID, err: fmt.Errorf("no gateway interface found attached to this network")} + } + return gatewayDetachedMsg{networkID: netID} + } +} + func (m Model) executeSubnetDelete() tea.Cmd { return func() tea.Msg { subnets, _ := m.detailData["_subnets"].([]map[string]any) @@ -2157,20 +2213,9 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { case ProductStorageBackup: m.table = createVolumeBackupsTable(msg.data, m.width, m.height) case ProductNetworkPrivate: - // Split into vRack (tab 0) and Local Zones (tab 1) - var vRack, localZones []map[string]interface{} - for _, net := range msg.data { - if getString(net, "_regionType") == "localzone" { - localZones = append(localZones, net) - } else { - vRack = append(vRack, net) - } - } - m.privNetLocalZones = localZones m.privNetTabIdx = 0 - // currentData = vRack slice (tab 0); full list kept in msg.data via normal path - m.currentData = vRack - m.table = createPrivateNetworksTable(vRack, m.width, m.height) + m.currentData = msg.data + m.table = createPrivateNetworksTable(msg.data, m.width, m.height) m.mode = TableView return m, nil case ProductNetworkPublic: diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 6928ece3..36efe61b 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -921,6 +921,11 @@ type regionDeletedMsg struct { err error } +type gatewayDetachedMsg struct { + networkID string + err error +} + type privNetDeletedMsg struct { networkName string err error @@ -1483,6 +1488,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case gatewayDetachedMsg: + m.actionConfirm = false + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Failed to detach gateway: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = "✅ Gateway detached from network" + m.notificationExpiry = time.Now().Add(5 * time.Second) + if m.detailData != nil { + return m, tea.Batch( + m.fetchPrivateNetworkDetail(msg.networkID), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + case subnetAddedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -5337,31 +5359,7 @@ func (m Model) renderObjectStorageWithTabs(tableContent string, width int) strin } func (m Model) renderPrivateNetworksWithTabs(tableContent string, width int) string { - var content strings.Builder - - tabActiveStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#7B68EE")). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 2) - tabInactiveStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#333333")). - Foreground(lipgloss.Color("#888888")). - Padding(0, 2) - - tab1 := "Régions (vRack)" - tab2 := "Local Zones" - var t1, t2 string - if m.privNetTabIdx == 0 { - t1 = tabActiveStyle.Render(tab1) - t2 = tabInactiveStyle.Render(tab2) - } else { - t1 = tabInactiveStyle.Render(tab1) - t2 = tabActiveStyle.Render(tab2) - } - content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, t1, " ", t2) + "\n\n") - content.WriteString(tableContent) - return content.String() + return tableContent } func (m Model) renderPublicIPsWithTabs(tableContent string, width int) string { @@ -5978,7 +5976,7 @@ func (m Model) renderPrivateNetworkDetail(width int) string { _ = netName // Actions - actions := []string{"Delete", "Assign Gateway", "Add Subnet", "Delete Subnet", "Delete Region"} + actions := []string{"Delete", "Assign Gateway", "Add Subnet", "Delete Subnet", "Delete Region", "Detach Gateway"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -6512,15 +6510,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus - if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && - (m.mode == TableView || m.mode == EmptyView) { - if m.privNetTabIdx > 0 { - m.privNetTabIdx = 0 - m.table = createPrivateNetworksTable(m.currentData, m.width, m.height) - } - return m, nil - } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -6591,7 +6580,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // In DetailView for Private Networks, navigate actions (0=Delete, 1=Assign Gateway, 2=Add Subnet, 3=Delete Subnet) if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { - if m.selectedAction < 4 { + if m.selectedAction < 5 { m.selectedAction++ m.actionConfirm = false } @@ -6611,15 +6600,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - // Private Networks: ←/→ switches between vRack and Local Zones tabs when in table focus - if m.inNetworkSubNav && m.inTableFocus && m.currentProduct == ProductNetworkPrivate && - (m.mode == TableView || m.mode == EmptyView) { - if m.privNetTabIdx < 1 { - m.privNetTabIdx = 1 - m.table = createPrivateNetworksTable(m.privNetLocalZones, m.width, m.height) - } - return m, nil - } // Object Storage: ←/→ switches between Containers and Users tabs when in table focus if m.inStorageSubNav && m.inTableFocus && m.currentProduct == ProductStorageObject && (m.mode == TableView || m.mode == EmptyView) { @@ -7019,6 +6999,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.executeRegionDelete() } m.actionConfirm = true + case 5: // Detach Gateway + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeGatewayDetachFromNetwork() + } + m.actionConfirm = true } return m, nil } else if m.mode == ProjectSelectView { From 4f7ee96ea788b7405b1d6a90f5fd009ade28f887 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 10:11:45 +0000 Subject: [PATCH 47/55] feat(browser): added name of gateway in private network Signed-off-by: olivier dubo --- internal/services/browser/api.go | 62 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 6876b025..7d89a47c 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1428,6 +1428,59 @@ func (m Model) fetchPrivateNetworksData() dataLoadedMsg { } } + // Enrich with gateway data: fetch all gateways across all regions then match by OpenStack network ID. + // Collect all unique region names used by these networks. + regionSet := map[string]bool{} + for _, n := range networks { + if regions, ok := n["regions"].([]interface{}); ok { + for _, rv := range regions { + if rm, ok := rv.(map[string]interface{}); ok { + if r := getString(rm, "region"); r != "" { + regionSet[r] = true + } + } + } + } + } + uniqueRegions := make([]any, 0, len(regionSet)) + for r := range regionSet { + uniqueRegions = append(uniqueRegions, r) + } + if len(uniqueRegions) > 0 { + gwRegionEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + allRegionGateways, _ := httpLib.FetchObjectsParallel[[]map[string]any](gwRegionEndpoint+"/%s/gateway", uniqueRegions, true) + // Build a map: openstackNetworkID → gateway name + openstackToGateway := map[string]string{} + for _, regionGWs := range allRegionGateways { + for _, gw := range regionGWs { + gwName := getString(gw, "name") + if interfaces, ok := gw["interfaces"].([]interface{}); ok { + for _, iface := range interfaces { + if ifaceMap, ok := iface.(map[string]interface{}); ok { + if netID := getString(ifaceMap, "networkId"); netID != "" { + openstackToGateway[netID] = gwName + } + } + } + } + } + } + // Match each network's openstackId against the map + for i, n := range networks { + if regions, ok := n["regions"].([]interface{}); ok { + for _, rv := range regions { + if rm, ok := rv.(map[string]interface{}); ok { + opID := getString(rm, "openstackId") + if gwName, found := openstackToGateway[opID]; found && gwName != "" { + networks[i]["_gatewayName"] = gwName + break + } + } + } + } + } + } + return dataLoadedMsg{ data: networks, err: nil, @@ -2593,9 +2646,10 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int {Title: "Name", Width: 22}, {Title: "Location", Width: 20}, {Title: "CIDR", Width: 18}, - {Title: "Gateway", Width: 16}, + {Title: "Gateway IP", Width: 16}, {Title: "DHCP", Width: 6}, {Title: "IP address allocated", Width: 34}, + {Title: "OVH Gateway", Width: 20}, } var rows []table.Row @@ -2655,7 +2709,11 @@ func createPrivateNetworksTable(data []map[string]interface{}, width, height int } } } - rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, allocPool}) + gwName := "-" + if v := getString(net, "_gatewayName"); v != "" { + gwName = v + } + rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, allocPool, gwName}) } tableHeight := height - 15 From ff087705fd04d9c15b58c63f7623b5e9dca75e1f Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 12:58:27 +0000 Subject: [PATCH 48/55] feat(browser): fixed name and region of lb Signed-off-by: olivier dubo --- internal/services/browser/lb_wizard.go | 3 +-- internal/services/browser/manager.go | 28 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/services/browser/lb_wizard.go b/internal/services/browser/lb_wizard.go index 746df25f..d52a6d03 100644 --- a/internal/services/browser/lb_wizard.go +++ b/internal/services/browser/lb_wizard.go @@ -79,8 +79,7 @@ func (m Model) fetchLBNetworks() tea.Cmd { } var filtered []map[string]interface{} for _, n := range nets { - name := getStringValue(n, "name", "") - if name != "" && name != "Ext-Net" { + if getStringValue(n, "visibility", "") == "private" { filtered = append(filtered, n) } } diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 36efe61b..3cdc18ea 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -1648,7 +1648,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = TableView return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) } + // Prefer name from API response, fall back to wizard input lbName := getString(msg.lb, "name") + if lbName == "" { + lbName = getString(msg.lb, "id") + } m.notification = fmt.Sprintf("✅ Load Balancer '%s' créé avec succès", lbName) m.notificationExpiry = time.Now().Add(5 * time.Second) m.mode = LoadingView @@ -1736,6 +1740,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.errorMsg = msg.err.Error() return m, nil } + // Sort flavors by known size order: small < medium < large < xl < xxl etc. + sizeOrder := map[string]int{ + "small": 1, "s": 1, + "medium": 2, "m": 2, + "large": 3, "l": 3, + "xl": 4, + "2xl": 5, "xxl": 5, + "3xl": 6, + } + sortRank := func(name string) int { + n := strings.ToLower(name) + if v, ok := sizeOrder[n]; ok { + return v + } + return 99 + } + sort.Slice(msg.flavors, func(i, j int) bool { + ri := sortRank(getStringValue(msg.flavors[i], "name", "")) + rj := sortRank(getStringValue(msg.flavors[j], "name", "")) + if ri != rj { + return ri < rj + } + return getStringValue(msg.flavors[i], "name", "") < getStringValue(msg.flavors[j], "name", "") + }) m.wizard.lbFlavors = msg.flavors m.wizard.lbFlavorIdx = 0 return m, nil From a79b5d866582ef54e069f2795a7953c9ddda5d51 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 14:12:08 +0000 Subject: [PATCH 49/55] feat(browser): added compute in principal nav Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 194 +++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 13 deletions(-) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 3cdc18ea..5a9685e2 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -196,6 +196,9 @@ const ( ProductNetworkGateway // Gateways (sub-nav) ProductNetworkLB // Load Balancers (sub-nav) ProductProjects + ProductCompute // Compute top-level nav + ProductInstanceBackup // Instance Backup (compute sub-nav) + ProductWorkflow // Workflow (compute sub-nav) ) // WizardData holds the state for the creation wizard @@ -466,6 +469,8 @@ type Model struct { inStorageSubNav bool // Whether the keyboard focus is in the storage sub-nav bar networkSubIdx int // Index in network sub-navigation inNetworkSubNav bool // Whether the keyboard focus is in the network sub-nav bar + computeSubIdx int // Index in compute sub-navigation + inComputeSubNav bool // Whether the keyboard focus is in the compute sub-nav bar inTableFocus bool // Whether the keyboard focus is in the table content (third navigation level) table table.Model detailData map[string]interface{} @@ -1023,7 +1028,7 @@ type privNetDetailLoadedMsg struct { func getNavItems() []NavItem { return []NavItem{ - {Label: "Instances", Icon: "💻", Product: ProductInstances, Path: "/instances"}, + {Label: "Compute", Icon: "💻", Product: ProductCompute, Path: "/instances"}, {Label: " Kubernetes", Icon: "☸️", Product: ProductKubernetes, Path: "/kubernetes"}, {Label: " Managed Databases", Icon: "🗄️", Product: ProductManagedDatabases, Path: "/databases"}, {Label: "Managed Analytics", Icon: "📈", Product: ProductManagedAnalytics, Path: "/analytics"}, @@ -1065,6 +1070,21 @@ func getNetworkSubItems() []NetworkSubItem { } } +type ComputeSubItem struct { + Label string + Product ProductType + Path string + Enabled bool +} + +func getComputeSubItems() []ComputeSubItem { + return []ComputeSubItem{ + {Label: "Instances", Product: ProductInstances, Path: "/instances", Enabled: true}, + {Label: "Instance Backup", Product: ProductInstanceBackup, Path: "/instances/backup", Enabled: false}, + {Label: "Workflow", Product: ProductWorkflow, Path: "/instances/workflow", Enabled: false}, + } +} + // StartBrowser is the entry point for the browser TUI func StartBrowser(cmd *cobra.Command, args []string) { // Reset creation command @@ -2344,7 +2364,7 @@ func (m Model) renderNavBar(width int) string { navItems := getNavItems() var items []string - isSubNavFocused := m.inStorageSubNav || m.inNetworkSubNav + isSubNavFocused := m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav isInSubContext := isSubNavFocused || m.inTableFocus for i, nav := range navItems { @@ -2382,6 +2402,14 @@ func (m Model) renderNavBar(width int) string { return mainNav + "\n" + subNav } + // Show compute sub-navigation when on Compute or any compute sub-product + isComputeSubProduct := m.currentProduct == ProductInstances || m.currentProduct == ProductInstanceBackup || m.currentProduct == ProductWorkflow + isComputeContext := navItems[m.navIdx].Product == ProductCompute || isComputeSubProduct + if isComputeContext { + subNav := m.renderComputeSubNav(width) + return mainNav + "\n" + subNav + } + return mainNav } @@ -2504,6 +2532,61 @@ func (m Model) renderNetworkSubNav(width int) string { return subBarStyle.Width(width - 2).Render(subContent) } +func (m Model) renderComputeSubNav(width int) string { + subItems := getComputeSubItems() + var items []string + + subItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Padding(0, 2) + subItemDisabledStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444444")). + Padding(0, 2) + + activeSubIdx := m.computeSubIdx + for i, item := range subItems { + if item.Product == m.currentProduct { + activeSubIdx = i + break + } + } + + for i, item := range subItems { + var style lipgloss.Style + label := item.Label + if i == activeSubIdx && m.inComputeSubNav && m.inTableFocus { + // Level 3: focus moved into the table — show arrow hint, dimmed + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) + label = "▼ " + item.Label + } else if i == activeSubIdx && m.inComputeSubNav { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Padding(0, 2) + } else if i == activeSubIdx { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA55")).Padding(0, 2) + } else if !item.Enabled { + style = subItemDisabledStyle + } else { + style = subItemStyle + } + items = append(items, style.Render(label)) + } + + borderColor := lipgloss.Color("#333333") + if m.inComputeSubNav && !m.inTableFocus { + // Level 2: sub-nav is focused — bright green border + borderColor = lipgloss.Color("#00FF7F") + } else if m.inTableFocus { + // Level 3: focus is inside the table — dim the sub-nav border + borderColor = lipgloss.Color("#444444") + } + subBarStyle := lipgloss.NewStyle(). + Padding(0, 1). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(borderColor) + subContent := lipgloss.JoinHorizontal(lipgloss.Top, items...) + return subBarStyle.Width(width - 2).Render(subContent) +} + func (m Model) renderContentBox(width int) string { var titleText string @@ -6278,7 +6361,7 @@ func (m Model) renderFooter() string { case TableView: if m.filterInput != "" { help = "←→: Switch Product • ↑↓: Navigate • /: Edit Filter • Enter: Details • c: Create • Del: Delete • d: Debug • Esc: Clear Filter • q: Quit" - } else if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { + } else if (m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav) && m.inTableFocus { if m.currentProduct == ProductNetworkPrivate { help = "↑↓: Navigate • ←→: Régions↔Local Zones • Enter: Détails • c: Create • /: Filter • d: Debug • Esc: Back • q: Quit" } else if m.currentProduct == ProductStorageObject { @@ -6286,15 +6369,15 @@ func (m Model) renderFooter() string { } else { help = "↑↓: Navigate • Enter: Détails • c: Create • /: Filter • d: Debug • Esc: Back to Sub-menu • q: Quit" } - } else if m.inStorageSubNav || m.inNetworkSubNav { + } else if m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav { help = "←→: Sub-menu • ↓/Enter: Enter Table • ↑/Esc: Back to main nav • d: Debug • p: Change Project • q: Quit" } else { help = "←→: Switch Product • Enter: Enter Sub-menu • ↑↓: Navigate • /: Filter • Enter: Details • c: Create • Del: Delete • d: Debug • p: Change Project • q: Quit" } case EmptyView: - if (m.inStorageSubNav || m.inNetworkSubNav) && m.inTableFocus { + if (m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav) && m.inTableFocus { help = "c: Create • d: Debug • Esc: Back to Sub-menu • q: Quit" - } else if m.inStorageSubNav || m.inNetworkSubNav { + } else if m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav { help = "←→: Sub-menu • Enter: Enter Table • ↑/Esc: Back to main nav • c: Create • d: Debug • p: Change Project • q: Quit" } else { help = "←→: Switch Product • Enter: Enter Sub-menu • c: Create • d: Debug • p: Change Project • q: Quit" @@ -6571,11 +6654,24 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.networkSubIdx = (m.networkSubIdx - 1 + len(subItems)) % len(subItems) return m.loadNetworkSubProduct() } + // In compute sub-nav (only when focused, not in table) + if m.inComputeSubNav && !m.inTableFocus && m.mode != DetailView { + subItems := getComputeSubItems() + for i, item := range subItems { + if item.Product == m.currentProduct { + m.computeSubIdx = i + break + } + } + m.computeSubIdx = (m.computeSubIdx - 1 + len(subItems)) % len(subItems) + return m.loadComputeSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects && !m.inTableFocus { if m.navIdx > 0 { m.navIdx-- m.inStorageSubNav = false m.inNetworkSubNav = false + m.inComputeSubNav = false return m.loadCurrentProduct() } } @@ -6663,12 +6759,26 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.networkSubIdx = (m.networkSubIdx + 1) % len(subItems) return m.loadNetworkSubProduct() } + // In compute sub-nav (only when focused, not in table) + isComputeSubProduct2 := m.currentProduct == ProductInstances || m.currentProduct == ProductInstanceBackup || m.currentProduct == ProductWorkflow + if m.inComputeSubNav && !m.inTableFocus && isComputeSubProduct2 && m.mode != DetailView { + subItems := getComputeSubItems() + for i, item := range subItems { + if item.Product == m.currentProduct { + m.computeSubIdx = i + break + } + } + m.computeSubIdx = (m.computeSubIdx + 1) % len(subItems) + return m.loadComputeSubProduct() + } if m.mode != ProjectSelectView && m.currentProduct != ProductProjects && !m.inTableFocus { navItems := getNavItems() if m.navIdx < len(navItems)-1 { m.navIdx++ m.inStorageSubNav = false m.inNetworkSubNav = false + m.inComputeSubNav = false return m.loadCurrentProduct() } } @@ -6709,7 +6819,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } // Level 3 → Level 2: exit table focus (back to sub-nav focus) - if m.inTableFocus && (m.inStorageSubNav || m.inNetworkSubNav) && m.mode != DetailView && m.mode != WizardView { + if m.inTableFocus && (m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav) && m.mode != DetailView && m.mode != WizardView { m.inTableFocus = false return m, nil } @@ -6722,6 +6832,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inNetworkSubNav = false return m, nil } + if m.inComputeSubNav && !m.inTableFocus && m.mode != DetailView && m.mode != WizardView { + m.inComputeSubNav = false + return m, nil + } // Go back to node pools view from node pool detail view, or cancel action confirm if m.mode == NodePoolDetailView { if m.nodePoolDetailConfirm { @@ -6808,6 +6922,19 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Level 3: fall through to normal enter handling } + if m.navIdx < len(navItems) && navItems[m.navIdx].Product == ProductCompute { + if !m.inComputeSubNav { + // Level 1 → Level 2: enter sub-nav focus + m.inComputeSubNav = true + m.inTableFocus = false + return m.loadComputeSubProduct() + } else if !m.inTableFocus { + // Level 2 → Level 3: enter table focus + m.inTableFocus = true + return m, nil + } + // Level 3: fall through to normal enter handling + } } // Handle enter based on current mode if m.mode == NodePoolDetailView { @@ -7135,11 +7262,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() isStorageSubProduct := m.currentProduct >= ProductStorageBlock && m.currentProduct <= ProductStorageArchive isNetworkSubProduct := m.currentProduct >= ProductNetworkPrivate && m.currentProduct <= ProductNetworkLB - isSubNavProduct := isStorageSubProduct || isNetworkSubProduct + isComputeSubProduct := m.currentProduct == ProductInstances || m.currentProduct == ProductInstanceBackup || m.currentProduct == ProductWorkflow + isSubNavProduct := isStorageSubProduct || isNetworkSubProduct || isComputeSubProduct navItems := getNavItems() - // Level 1 → Level 2: ↓ from main nav enters sub-nav for Storage / Networks - if (key == "down" || key == "j") && !m.inStorageSubNav && !m.inNetworkSubNav && !m.inTableFocus && + // Level 1 → Level 2: ↓ from main nav enters sub-nav for Storage / Networks / Compute + if (key == "down" || key == "j") && !m.inStorageSubNav && !m.inNetworkSubNav && !m.inComputeSubNav && !m.inTableFocus && m.mode != DetailView && m.mode != ProjectSelectView { if navItems[m.navIdx].Product == ProductStorage { m.inStorageSubNav = true @@ -7151,6 +7279,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inTableFocus = false return m.loadNetworkSubProduct() } + if navItems[m.navIdx].Product == ProductCompute { + m.inComputeSubNav = true + m.inTableFocus = false + return m.loadComputeSubProduct() + } } // In table focus (Level 3): up at row 0 → back to sub-nav focus (Level 2) @@ -7169,8 +7302,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inNetworkSubNav = false return m, nil } + if (key == "up" || key == "k") && m.inComputeSubNav && !m.inTableFocus && m.mode != DetailView { + m.inComputeSubNav = false + return m, nil + } // In sub-nav focus (Level 2): down → enter table focus (Level 3) - if (key == "down" || key == "j") && isSubNavProduct && (m.inStorageSubNav || m.inNetworkSubNav) && !m.inTableFocus && m.mode != DetailView { + if (key == "down" || key == "j") && isSubNavProduct && (m.inStorageSubNav || m.inNetworkSubNav || m.inComputeSubNav) && !m.inTableFocus && m.mode != DetailView { m.inTableFocus = true return m, nil } @@ -9133,6 +9270,7 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { m.currentData = nil m.inStorageSubNav = false m.inNetworkSubNav = false + m.inComputeSubNav = false m.inTableFocus = false // For Networks, go to default sub-item (Private Networks = index 0) @@ -9147,10 +9285,16 @@ func (m Model) loadCurrentProduct() (Model, tea.Cmd) { return m.loadStorageSubProduct() } + // For Compute, go to default sub-item (Instances = index 0) + if currentNav.Product == ProductCompute { + m.computeSubIdx = 0 + return m.loadComputeSubProduct() + } + m.mode = LoadingView - // For instances and Kubernetes, start the auto-refresh timer - if currentNav.Product == ProductInstances || currentNav.Product == ProductKubernetes { + // For Kubernetes, start the auto-refresh timer + if currentNav.Product == ProductKubernetes { return m, tea.Batch( m.fetchDataForPath(currentNav.Path), m.scheduleRefresh(), @@ -9195,6 +9339,30 @@ func (m Model) loadNetworkSubProduct() (Model, tea.Cmd) { return m, m.fetchDataForPath(sub.Path) } +func (m Model) loadComputeSubProduct() (Model, tea.Cmd) { + subItems := getComputeSubItems() + sub := subItems[m.computeSubIdx] + m.currentProduct = sub.Product + m.detailData = nil + m.currentData = nil + m.inTableFocus = false + + if !sub.Enabled { + m.mode = ComingSoonView + return m, nil + } + + m.mode = LoadingView + // Instances need the auto-refresh timer + if sub.Product == ProductInstances { + return m, tea.Batch( + m.fetchDataForPath(sub.Path), + m.scheduleRefresh(), + ) + } + return m, m.fetchDataForPath(sub.Path) +} + // Helper functions func getStringValue(data map[string]interface{}, key string, defaultVal string) string { if val, ok := data[key]; ok { From ae2f1c12814dce09034a0f17a6951c5d901407db Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Thu, 7 May 2026 15:02:47 +0000 Subject: [PATCH 50/55] feat(browser): added instance backup in compute Signed-off-by: olivier dubo --- internal/services/browser/api.go | 142 ++++++++++++++++++++++++++- internal/services/browser/manager.go | 8 +- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 7d89a47c..03e58589 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -129,6 +129,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/instances/backup": + return func() tea.Msg { + msg := m.fetchInstanceBackupsData() + msg.forProduct = product + return msg + } case "/storage/snapshot": return func() tea.Msg { msg := m.fetchVolumeSnapshotsData() @@ -2168,6 +2174,11 @@ func (m Model) handleInstancesLoaded(msg instancesLoadedMsg) (tea.Model, tea.Cmd // handleInstancesEnriched processes the enriched instances data (images and floating IPs) func (m Model) handleInstancesEnriched(msg instancesEnrichedMsg) (tea.Model, tea.Cmd) { + // Discard stale enrichment if the user has already switched away from Instances + if m.currentProduct != ProductInstances { + return m, nil + } + // Preserve cursor position before recreating table currentCursor := m.table.Cursor() @@ -2261,6 +2272,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { } else { m.table = createObjectStorageUsersTable(msg.s3Users, m.width, m.height) } + case ProductInstanceBackup: + m.table = createInstanceBackupsTable(msg.data, m.width, m.height) case ProductStorageSnapshot: m.table = createVolumeSnapshotsTable(msg.data, m.width, m.height) case ProductStorageBackup: @@ -2496,6 +2509,100 @@ func createInstancesTable(instances []map[string]interface{}, imageMap map[strin return t } +// createInstanceBackupsTable creates a table for instance backups (snapshots). +// Columns: Name, Region, Model (flavorType), Size, Status, Created +func createInstanceBackupsTable(data []map[string]interface{}, width, height int) table.Model { + sort.Slice(data, func(i, j int) bool { + return getString(data[i], "name") < getString(data[j], "name") + }) + + columns := []table.Column{ + {Title: "Name", Width: 28}, + {Title: "ID", Width: 28}, + {Title: "Location", Width: 14}, + {Title: "Size", Width: 14}, + {Title: "Creation date", Width: 28}, + {Title: "Status", Width: 20}, + } + + var rows []table.Row + for _, s := range data { + // Name + name := getString(s, "name") + + // ID + id := getString(s, "id") + + // Location: snapshots expose "regions" as []interface{} or "region" as string + location := getString(s, "region") + if location == "" { + if regions, ok := s["regions"].([]interface{}); ok && len(regions) > 0 { + var regionNames []string + for _, r := range regions { + if rs, ok := r.(string); ok { + regionNames = append(regionNames, rs) + } + } + location = strings.Join(regionNames, ", ") + } + } + if location == "" { + location = "-" + } + + // Size: minDisk is the original disk size in GB + sizeStr := "-" + switch v := s["minDisk"].(type) { + case float64: + if v > 0 { + sizeStr = fmt.Sprintf("%.0f GB", v) + } + case int: + if v > 0 { + sizeStr = fmt.Sprintf("%d GB", v) + } + } + + // Creation date: truncate ISO to YYYY-MM-DD HH:MM + created := getString(s, "creationDate") + if len(created) >= 16 { + created = created[:16] + } + + // Status + status := getString(s, "status") + + rows = append(rows, table.Row{name, id, location, sizeStr, created, status}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + return t +} + // createKubernetesTable creates a table for Kubernetes clusters func createKubernetesTable(clusters []map[string]interface{}, width, height int) table.Model { // Sort clusters by name for stable ordering @@ -4147,7 +4254,7 @@ func (m Model) executeInstanceAction(actionIndex int) tea.Cmd { return instanceActionMsg{err: fmt.Errorf("instance ID not found")} } - actions := []string{"ssh", "reboot", "rescue", "stop_or_start", "vnc", "reinstall"} + actions := []string{"ssh", "reboot", "rescue", "stop_or_start", "vnc", "reinstall", "backup"} if actionIndex < 0 || actionIndex >= len(actions) { return instanceActionMsg{err: fmt.Errorf("invalid action index")} } @@ -4273,6 +4380,17 @@ func (m Model) executeInstanceAction(actionIndex int) tea.Cmd { endpoint := fmt.Sprintf("/v1/cloud/project/%s/instance/%s/reinstall", m.cloudProject, instanceId) body := map[string]string{"imageId": imageId} err = httpLib.Client.Post(endpoint, body, nil) + + case "backup": + // POST /cloud/project/{serviceName}/instance/{instanceId}/snapshot + instanceName := getString(m.detailData, "name") + snapshotName := fmt.Sprintf("%s-backup-%s", instanceName, time.Now().Format("2006-01-02-1504")) + endpoint := fmt.Sprintf("/v1/cloud/project/%s/instance/%s/snapshot", m.cloudProject, instanceId) + body := map[string]string{"snapshotName": snapshotName} + err = httpLib.Client.Post(endpoint, body, nil) + if err == nil { + return instanceActionMsg{action: "backup", instanceId: instanceId, backupName: snapshotName, err: nil} + } } return instanceActionMsg{ @@ -4294,6 +4412,7 @@ func (m Model) handleInstanceAction(msg instanceActionMsg) (tea.Model, tea.Cmd) "start": "Start", "vnc": "Console", "reinstall": "Reinstall", + "backup": "Instance Backup", } actionName := actionNames[msg.action] @@ -4314,6 +4433,8 @@ func (m Model) handleInstanceAction(msg instanceActionMsg) (tea.Model, tea.Cmd) } else { if msg.action == "ssh" { m.notification = "✅ SSH session ended" + } else if msg.action == "backup" && msg.backupName != "" { + m.notification = fmt.Sprintf("✅ Instance Backup \"%s\" initiated successfully!", msg.backupName) } else { m.notification = fmt.Sprintf("✅ %s initiated successfully!", actionName) } @@ -5298,6 +5419,25 @@ func (m Model) handleNodePoolDeleted(msg nodePoolDeletedMsg) (tea.Model, tea.Cmd return m, m.fetchKubeNodePools(clusterId) } +// fetchInstanceBackupsData fetches the list of instance snapshots (backups). +func (m Model) fetchInstanceBackupsData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/snapshot", m.cloudProject) + var raw []interface{} + if err := httpLib.Client.Get(endpoint, &raw); err != nil { + return dataLoadedMsg{err: err} + } + var snapshots []map[string]interface{} + for _, item := range raw { + if obj, ok := item.(map[string]interface{}); ok { + snapshots = append(snapshots, obj) + } + } + return dataLoadedMsg{data: snapshots} +} + // fetchVolumeSnapshotsData fetches the list of volume snapshots. func (m Model) fetchVolumeSnapshotsData() dataLoadedMsg { if m.cloudProject == "" { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 5a9685e2..20e4fd44 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -252,6 +252,7 @@ type WizardData struct { filterInput string // Current filter input text for wizard lists // Cleanup tracking - IDs of resources created during wizard createdSSHKeyId string // ID of SSH key created during wizard + createdNetworkId string // ID of network created during wizard createdSubnetId string // ID of subnet created during wizard createdGatewayId string // ID of gateway created during wizard @@ -754,6 +755,7 @@ type progressMsg struct { type instanceActionMsg struct { action string instanceId string + backupName string err error } @@ -1080,7 +1082,7 @@ type ComputeSubItem struct { func getComputeSubItems() []ComputeSubItem { return []ComputeSubItem{ {Label: "Instances", Product: ProductInstances, Path: "/instances", Enabled: true}, - {Label: "Instance Backup", Product: ProductInstanceBackup, Path: "/instances/backup", Enabled: false}, + {Label: "Instance Backup", Product: ProductInstanceBackup, Path: "/instances/backup", Enabled: true}, {Label: "Workflow", Product: ProductWorkflow, Path: "/instances/workflow", Enabled: false}, } } @@ -5696,7 +5698,7 @@ func (m Model) renderInstanceDetail(width int) string { if strings.ToUpper(status) == "RESCUE" { rescueAction = "Exit Rescue" } - actions := []string{"SSH", "Reboot", rescueAction, stopStartAction, "Console", "Reinstall"} + actions := []string{"SSH", "Reboot", rescueAction, stopStartAction, "Console", "Reinstall", "Backup"} var actionParts []string for i, action := range actions { if i == m.selectedAction { @@ -6688,7 +6690,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // In DetailView, navigate actions if m.mode == DetailView && m.currentProduct == ProductInstances { - if m.selectedAction < 5 { // 6 actions: 0-5 + if m.selectedAction < 6 { // 7 actions: 0-6 m.selectedAction++ m.actionConfirm = false } From ef39dd4781bb9354a27373c586f852b669b42475 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 11 May 2026 07:26:13 +0000 Subject: [PATCH 51/55] feat(compute): fixed navigation in instance Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 20e4fd44..6e13e092 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -6874,6 +6874,15 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if isSubNavProd && !m.inTableFocus { return m, nil } + // Instance Backup: redirect to Instances and launch the instance creation wizard + // (a backup is created from an instance, not as a standalone resource) + if m.currentProduct == ProductInstanceBackup { + m.computeSubIdx = 0 // Instances is index 0 in compute sub-nav + m.currentProduct = ProductInstances + m.inComputeSubNav = true + m.inTableFocus = true + return m, m.launchCreationWizard() + } // If viewing S3 users tab, launch user creation wizard if m.currentProduct == ProductStorageObject && m.objectStorageTabIdx == 1 { m.mode = WizardView From dea4e93703bdb74470e098903635d6929eef8c63 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 11 May 2026 08:04:34 +0000 Subject: [PATCH 52/55] feat(compute): added informations in workflow Signed-off-by: olivier dubo --- internal/services/browser/api.go | 175 +++++++++++++++++++++++++++ internal/services/browser/manager.go | 2 +- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 03e58589..bb40452b 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -135,6 +135,12 @@ func (m Model) fetchDataForPath(path string) tea.Cmd { msg.forProduct = product return msg } + case "/instances/workflow": + return func() tea.Msg { + msg := m.fetchWorkflowsData() + msg.forProduct = product + return msg + } case "/storage/snapshot": return func() tea.Msg { msg := m.fetchVolumeSnapshotsData() @@ -2274,6 +2280,8 @@ func (m Model) handleDataLoaded(msg dataLoadedMsg) (tea.Model, tea.Cmd) { } case ProductInstanceBackup: m.table = createInstanceBackupsTable(msg.data, m.width, m.height) + case ProductWorkflow: + m.table = createWorkflowsTable(msg.data, m.width, m.height) case ProductStorageSnapshot: m.table = createVolumeSnapshotsTable(msg.data, m.width, m.height) case ProductStorageBackup: @@ -2603,6 +2611,86 @@ func createInstanceBackupsTable(data []map[string]interface{}, width, height int return t } +// createWorkflowsTable creates a table for backup workflows. +// Columns: Name, ID, Backup, Location, Workflow (cron), Targeted Resource, Rotation, Last Execution, Last State +func createWorkflowsTable(data []map[string]interface{}, width, height int) table.Model { + sort.Slice(data, func(i, j int) bool { + return getString(data[i], "name") < getString(data[j], "name") + }) + + columns := []table.Column{ + {Title: "Name", Width: 22}, + {Title: "ID", Width: 22}, + {Title: "Backup", Width: 20}, + {Title: "Location", Width: 12}, + {Title: "Workflow", Width: 16}, + {Title: "Targeted Resource", Width: 22}, + {Title: "Ordinance", Width: 14}, + } + + var rows []table.Row + for _, w := range data { + name := getString(w, "name") + id := getString(w, "id") + + // Backup name prefix + backup := getString(w, "backupName") + if backup == "" { + backup = "-" + } + + // Location / region + location := getString(w, "region") + if location == "" { + location = "-" + } + + // Workflow = type, always "Backup" for this endpoint + workflowStr := "Backup" + + // Targeted resource = instanceId + instanceId := getString(w, "instanceId") + if instanceId == "" { + instanceId = "-" + } + + // Ordinance = raw cron expression (e.g. "0 0 */7 * *") + ordinance := getString(w, "cron") + if ordinance == "" { + ordinance = "-" + } + + rows = append(rows, table.Row{name, id, backup, location, workflowStr, instanceId, ordinance}) + } + + tableHeight := height - 15 + if tableHeight < 5 { + tableHeight = 5 + } + if tableHeight > 20 { + tableHeight = 20 + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + return t +} + // createKubernetesTable creates a table for Kubernetes clusters func createKubernetesTable(clusters []map[string]interface{}, width, height int) table.Model { // Sort clusters by name for stable ordering @@ -3218,6 +3306,51 @@ func getString(m map[string]interface{}, key string) string { return "" } +// parseCronHuman converts a cron expression to a human-readable string. +// e.g. "0 3 * * *" → "Daily at 03:00", "0 3 * * 1" → "Weekly Mon at 03:00" +func parseCronHuman(cron string) string { + if cron == "" { + return "-" + } + parts := strings.Fields(cron) + if len(parts) != 5 { + return cron + } + minute, hour, dom, month, dow := parts[0], parts[1], parts[2], parts[3], parts[4] + + timeStr := "" + if hour != "*" && minute != "*" { + h := hour + mi := minute + if len(h) == 1 { + h = "0" + h + } + if len(mi) == 1 { + mi = "0" + mi + } + timeStr = " at " + h + ":" + mi + } + + days := map[string]string{ + "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", + "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun", + } + + switch { + case dom != "*" && month == "*" && dow == "*": + return fmt.Sprintf("Monthly (day %s)%s", dom, timeStr) + case dow != "*" && dom == "*": + if day, ok := days[dow]; ok { + return fmt.Sprintf("Weekly %s%s", day, timeStr) + } + return fmt.Sprintf("Weekly%s", timeStr) + case dom == "*" && month == "*" && dow == "*": + return fmt.Sprintf("Daily%s", timeStr) + default: + return cron + } +} + // ============================================ // Wizard API functions for instance creation // ============================================ @@ -5438,6 +5571,48 @@ func (m Model) fetchInstanceBackupsData() dataLoadedMsg { return dataLoadedMsg{data: snapshots} } +// fetchWorkflowsData fetches the list of backup workflows across all project regions, +// then enriches each entry with lastExecution and lastExecutionStatus via individual GET. +func (m Model) fetchWorkflowsData() dataLoadedMsg { + if m.cloudProject == "" { + return dataLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var regionNames []string + regionsEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region", m.cloudProject) + if err := httpLib.Client.Get(regionsEndpoint, ®ionNames); err != nil { + return dataLoadedMsg{err: fmt.Errorf("failed to fetch regions: %w", err)} + } + var workflows []map[string]interface{} + for _, region := range regionNames { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/workflow/backup", m.cloudProject, url.PathEscape(region)) + var raw []map[string]interface{} + if err := httpLib.Client.Get(endpoint, &raw); err != nil { + continue + } + for _, obj := range raw { + if _, hasRegion := obj["region"]; !hasRegion { + obj["region"] = region + } + // Fetch individual workflow to get lastExecution + lastExecutionStatus + if wfID, ok := obj["id"].(string); ok && wfID != "" { + detailEndpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/workflow/backup/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(wfID)) + var detail map[string]interface{} + if err := httpLib.Client.Get(detailEndpoint, &detail); err == nil { + if v, ok := detail["lastExecution"]; ok { + obj["lastExecution"] = v + } + if v, ok := detail["lastExecutionStatus"]; ok { + obj["lastExecutionStatus"] = v + } + } + } + workflows = append(workflows, obj) + } + } + return dataLoadedMsg{data: workflows} +} + // fetchVolumeSnapshotsData fetches the list of volume snapshots. func (m Model) fetchVolumeSnapshotsData() dataLoadedMsg { if m.cloudProject == "" { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 6e13e092..f1e2a745 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -1083,7 +1083,7 @@ func getComputeSubItems() []ComputeSubItem { return []ComputeSubItem{ {Label: "Instances", Product: ProductInstances, Path: "/instances", Enabled: true}, {Label: "Instance Backup", Product: ProductInstanceBackup, Path: "/instances/backup", Enabled: true}, - {Label: "Workflow", Product: ProductWorkflow, Path: "/instances/workflow", Enabled: false}, + {Label: "Workflow", Product: ProductWorkflow, Path: "/instances/workflow", Enabled: true}, } } From c72689801a035d4d64119eef8c1ec1e64edfd51d Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 11 May 2026 08:20:51 +0000 Subject: [PATCH 53/55] feat(compute): added creation of workflow Signed-off-by: olivier dubo --- internal/services/browser/manager.go | 92 ++++- internal/services/browser/workflow_wizard.go | 355 +++++++++++++++++++ 2 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 internal/services/browser/workflow_wizard.go diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index f1e2a745..303ff21d 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -175,6 +175,15 @@ const ( FIPWizardStepConfirm // confirm + create ) +const ( + // Workflow wizard steps (offset by 1200) + WorkflowWizardStepType WizardStep = iota + 1200 // select workflow type + WorkflowWizardStepInstance // select instance + WorkflowWizardStepName // enter name + WorkflowWizardStepSchedule // define schedule/rotation + WorkflowWizardStepConfirm // confirm + create +) + // ProductType represents a product category type ProductType int @@ -456,6 +465,20 @@ type WizardData struct { fipInstanceId string fipInstanceName string fipConfirmBtnIdx int + + // Workflow wizard fields + wfInstances []map[string]interface{} + wfInstanceIdx int + wfInstanceId string + wfInstanceName string + wfRegion string + wfName string + wfNameInput string + wfScheduleIdx int // 0=rotation7, 1=rotation14, 2=custom + wfCron string + wfCronInput string + wfRotation int + wfConfirmBtnIdx int } // Model represents the TUI application state @@ -1285,6 +1308,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadingMessage: "Loading regions...", } return m, m.fetchFIPRegions() + } else if msg.product == ProductWorkflow { + m.mode = WizardView + m.wizard = WizardData{ + step: WorkflowWizardStepType, + } + return m, nil } // Store the creation command to be displayed after exit _, cmd := m.getProductCreationInfo() @@ -1834,6 +1863,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.fipInstanceIdx = 0 return m, nil + case workflowInstancesLoadedMsg: + m.wizard.isLoading = false + m.wizard.loadingMessage = "" + if msg.err != nil { + m.wizard.errorMsg = msg.err.Error() + return m, nil + } + m.wizard.wfInstances = msg.instances + m.wizard.wfInstanceIdx = 0 + return m, nil + + case workflowCreatedMsg: + m.wizard = WizardData{} + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Batch( + m.fetchDataForPath("/instances/workflow"), + tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + } + m.notification = fmt.Sprintf("✅ Workflow \"%s\" créé avec succès !", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/instances/workflow"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case fipCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -2595,7 +2654,10 @@ func (m Model) renderContentBox(width int) string { // Handle wizard mode with special title if m.mode == WizardView { // Determine which wizard we're in based on the step - if m.wizard.step >= 1100 { + if m.wizard.step >= 1200 { + // Workflow wizard + titleText = " ⚙️ Créer un Workflow de sauvegarde " + } else if m.wizard.step >= 1100 { // Floating IP wizard titleText = " 🌐 Create Floating IP " } else if m.wizard.step >= 1000 { @@ -3557,7 +3619,11 @@ func (m Model) renderWizardView(width int) string { var stepMapping []WizardStep // Maps display index to actual step // Build steps based on which wizard we're in (determine by first step >= 100) - if m.wizard.step >= 1100 { + if m.wizard.step >= 1200 { + // Workflow wizard + steps = append(steps, "Type", "Instance", "Nom", "Planification", "Confirmer") + stepMapping = append(stepMapping, WorkflowWizardStepType, WorkflowWizardStepInstance, WorkflowWizardStepName, WorkflowWizardStepSchedule, WorkflowWizardStepConfirm) + } else if m.wizard.step >= 1100 { // Floating IP wizard steps = append(steps, "Region", "Instance", "Confirm") stepMapping = append(stepMapping, FIPWizardStepRegion, FIPWizardStepInstance, FIPWizardStepConfirm) @@ -3808,6 +3874,17 @@ func (m Model) renderWizardView(width int) string { content.WriteString(m.renderFIPWizardInstanceStep(width)) case FIPWizardStepConfirm: content.WriteString(m.renderFIPWizardConfirmStep(width)) + // Workflow wizard steps + case WorkflowWizardStepType: + content.WriteString(m.renderWorkflowWizardTypeStep(width)) + case WorkflowWizardStepInstance: + content.WriteString(m.renderWorkflowWizardInstanceStep(width)) + case WorkflowWizardStepName: + content.WriteString(m.renderWorkflowWizardNameStep(width)) + case WorkflowWizardStepSchedule: + content.WriteString(m.renderWorkflowWizardScheduleStep(width)) + case WorkflowWizardStepConfirm: + content.WriteString(m.renderWorkflowWizardConfirmStep(width)) // Volume Backup / Snapshot wizard steps case BackupWizardStepVolume, BackupWizardStepType, BackupWizardStepName, BackupWizardStepConfirm: content.WriteString(m.renderBackupWizard(width)) @@ -7946,6 +8023,17 @@ func (m Model) handleWizardKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFIPWizardInstanceKeys(key) case FIPWizardStepConfirm: return m.handleFIPWizardConfirmKeys(key) + // Workflow wizard steps + case WorkflowWizardStepType: + return m.handleWorkflowWizardTypeKeys(key) + case WorkflowWizardStepInstance: + return m.handleWorkflowWizardInstanceKeys(key) + case WorkflowWizardStepName: + return m.handleWorkflowWizardNameKeys(key) + case WorkflowWizardStepSchedule: + return m.handleWorkflowWizardScheduleKeys(key) + case WorkflowWizardStepConfirm: + return m.handleWorkflowWizardConfirmKeys(key) } return m, nil } diff --git a/internal/services/browser/workflow_wizard.go b/internal/services/browser/workflow_wizard.go new file mode 100644 index 00000000..2bf0d83d --- /dev/null +++ b/internal/services/browser/workflow_wizard.go @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !(js && wasm) + +package browser + +import ( + "fmt" + "net/url" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// ─── Render ────────────────────────────────────────────────────────────────── + +func (m Model) renderWorkflowWizardTypeStep(width int) string { + var b strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF7F")). + Padding(1, 2). + Width(width - 8) + + b.WriteString(titleStyle.Render("Sélectionnez un Workflow") + "\n\n") + b.WriteString(descStyle.Render("Un Workflow décrit une ou plusieurs actions.") + "\n\n") + + inner := selectedStyle.Render("▶ Sauvegarde automatisée des instances") + "\n\n" + + descStyle.Render("Ce Workflow générera des sauvegardes d'Instance, les sauvegardes\npourront être utilisées pour démarrer de nouvelles Instances.") + + b.WriteString(boxStyle.Render(inner) + "\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Enter : Continuer • Esc : Annuler")) + return b.String() +} + +func (m Model) renderWorkflowWizardInstanceStep(width int) string { + var b strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + + b.WriteString(titleStyle.Render("Sélectionnez une Instance à sauvegarder :") + "\n\n") + + if m.wizard.isLoading { + b.WriteString(loadingStyle.Render("⏳ Chargement des instances...")) + return b.String() + } + if m.wizard.errorMsg != "" { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + if len(m.wizard.wfInstances) == 0 { + b.WriteString(descStyle.Render("Aucune instance disponible.") + "\n") + } else { + maxVisible := 14 + start := 0 + if m.wizard.wfInstanceIdx >= maxVisible { + start = m.wizard.wfInstanceIdx - maxVisible + 1 + } + end := start + maxVisible + if end > len(m.wizard.wfInstances) { + end = len(m.wizard.wfInstances) + } + for i := start; i < end; i++ { + inst := m.wizard.wfInstances[i] + label := getStringValue(inst, "name", getStringValue(inst, "id", "unknown")) + region := getStringValue(inst, "region", "") + if region != "" { + label += " " + descStyle.Render("("+region+")") + } + if i == m.wizard.wfInstanceIdx { + b.WriteString(selectedStyle.Render("▶ "+label) + "\n") + } else { + b.WriteString(dimStyle.Render(" "+label) + "\n") + } + } + } + b.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ : Naviguer • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return b.String() +} + +func (m Model) renderWorkflowWizardNameStep(width int) string { + var b strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + inputStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1A1A2E")). + Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1).Width(40) + + b.WriteString(titleStyle.Render("Donnez un nom à ce Workflow :") + "\n\n") + b.WriteString(" Nom : " + inputStyle.Render(m.wizard.wfNameInput+"█") + "\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("Tapez le nom • Enter : Valider • ← : Retour • Esc : Annuler")) + return b.String() +} + +func (m Model) renderWorkflowWizardScheduleStep(width int) string { + var b strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + inputStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1A1A2E")). + Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1).Width(30) + + b.WriteString(titleStyle.Render("Définir l'ordonnancement") + "\n\n") + + options := []struct{ label, desc string }{ + {"Rotation 7", "Conserver les 7 dernières sauvegardes"}, + {"Rotation 14", "Conserver les 14 dernières sauvegardes"}, + {"Personnalisé", "Définir votre propre planification"}, + } + + for i, opt := range options { + if i == m.wizard.wfScheduleIdx { + b.WriteString(selectedStyle.Render("▶ "+opt.label) + "\n") + b.WriteString(" " + descStyle.Render(opt.desc) + "\n") + if i == 2 { + // Custom cron input + b.WriteString("\n Cron : " + inputStyle.Render(m.wizard.wfCronInput+"█") + "\n") + b.WriteString(" " + descStyle.Render("ex: 0 0 * * * (tous les jours à minuit)") + "\n") + b.WriteString(" Rotation : " + inputStyle.Render(fmt.Sprintf("%d", m.wizard.wfRotation)) + "\n") + } + b.WriteString("\n") + } else { + b.WriteString(dimStyle.Render(" "+opt.label) + "\n") + b.WriteString(" " + descStyle.Render(opt.desc) + "\n\n") + } + } + + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("↑↓ : Choisir • Enter : Sélectionner • ← : Retour • Esc : Annuler")) + return b.String() +} + +func (m Model) renderWorkflowWizardConfirmStep(width int) string { + var b strings.Builder + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + b.WriteString(titleStyle.Render("Confirmer la création du Workflow :") + "\n\n") + b.WriteString(labelStyle.Render(" Workflow :") + valStyle.Render("Sauvegarde automatisée") + "\n") + instName := m.wizard.wfInstanceName + if instName == "" { + instName = m.wizard.wfInstanceId + } + b.WriteString(labelStyle.Render(" Instance :") + valStyle.Render(instName) + "\n") + b.WriteString(labelStyle.Render(" Région :") + valStyle.Render(m.wizard.wfRegion) + "\n") + b.WriteString(labelStyle.Render(" Nom :") + valStyle.Render(m.wizard.wfName) + "\n") + b.WriteString(labelStyle.Render(" Cron :") + valStyle.Render(m.wizard.wfCron) + "\n") + b.WriteString(labelStyle.Render(" Rotation :") + valStyle.Render(fmt.Sprintf("%d sauvegardes", m.wizard.wfRotation)) + "\n\n") + + if m.wizard.isLoading { + b.WriteString(loadingStyle.Render("⏳ Création en cours...")) + return b.String() + } + if m.wizard.errorMsg != "" { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Erreur : "+m.wizard.errorMsg) + "\n\n") + } + + btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ") + btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ") + if m.wizard.wfConfirmBtnIdx == 1 { + btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ") + btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ") + } + b.WriteString(btnCreate + " " + btnCancel + "\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")). + Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler")) + return b.String() +} + +// ─── Key handlers ───────────────────────────────────────────────────────────── + +func (m Model) handleWorkflowWizardTypeKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "enter": + // Only one type available — load instances and go to next step + m.wizard.wfInstanceIdx = 0 + m.wizard.step = WorkflowWizardStepInstance + m.wizard.isLoading = true + m.wizard.loadingMessage = "Chargement des instances..." + return m, m.fetchWorkflowInstances() + } + return m, nil +} + +func (m Model) handleWorkflowWizardInstanceKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.wfInstanceIdx > 0 { + m.wizard.wfInstanceIdx-- + } + case "down", "j": + if m.wizard.wfInstanceIdx < len(m.wizard.wfInstances)-1 { + m.wizard.wfInstanceIdx++ + } + case "enter": + if len(m.wizard.wfInstances) > 0 { + inst := m.wizard.wfInstances[m.wizard.wfInstanceIdx] + m.wizard.wfInstanceId = getStringValue(inst, "id", "") + m.wizard.wfInstanceName = getStringValue(inst, "name", m.wizard.wfInstanceId) + m.wizard.wfRegion = getStringValue(inst, "region", "") + m.wizard.wfNameInput = "" + m.wizard.step = WorkflowWizardStepName + } + case "left": + m.wizard.step = WorkflowWizardStepType + } + return m, nil +} + +func (m Model) handleWorkflowWizardNameKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "enter": + name := strings.TrimSpace(m.wizard.wfNameInput) + if name == "" { + return m, nil + } + m.wizard.wfName = name + m.wizard.wfScheduleIdx = 0 + m.wizard.wfCronInput = "0 0 * * *" + m.wizard.wfRotation = 7 + m.wizard.step = WorkflowWizardStepSchedule + case "left": + m.wizard.step = WorkflowWizardStepInstance + case "backspace": + if len(m.wizard.wfNameInput) > 0 { + m.wizard.wfNameInput = m.wizard.wfNameInput[:len(m.wizard.wfNameInput)-1] + } + default: + if len(key) == 1 { + m.wizard.wfNameInput += key + } + } + return m, nil +} + +func (m Model) handleWorkflowWizardScheduleKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.wizard.wfScheduleIdx > 0 { + m.wizard.wfScheduleIdx-- + } + case "down", "j": + if m.wizard.wfScheduleIdx < 2 { + m.wizard.wfScheduleIdx++ + } + case "enter": + switch m.wizard.wfScheduleIdx { + case 0: + m.wizard.wfCron = "0 0 * * *" + m.wizard.wfRotation = 7 + case 1: + m.wizard.wfCron = "0 0 * * *" + m.wizard.wfRotation = 14 + case 2: + cron := strings.TrimSpace(m.wizard.wfCronInput) + if cron == "" { + return m, nil + } + m.wizard.wfCron = cron + } + m.wizard.wfConfirmBtnIdx = 0 + m.wizard.step = WorkflowWizardStepConfirm + case "left": + if m.wizard.wfScheduleIdx != 2 { + m.wizard.step = WorkflowWizardStepName + } + case "backspace": + if m.wizard.wfScheduleIdx == 2 && len(m.wizard.wfCronInput) > 0 { + m.wizard.wfCronInput = m.wizard.wfCronInput[:len(m.wizard.wfCronInput)-1] + } + default: + if m.wizard.wfScheduleIdx == 2 && len(key) == 1 { + m.wizard.wfCronInput += key + } + } + return m, nil +} + +func (m Model) handleWorkflowWizardConfirmKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.wizard.wfConfirmBtnIdx = 0 + case "right", "l": + m.wizard.wfConfirmBtnIdx = 1 + case "enter": + if m.wizard.wfConfirmBtnIdx == 1 { + m.wizard.step = WorkflowWizardStepSchedule + return m, nil + } + m.wizard.isLoading = true + m.wizard.loadingMessage = "Création du Workflow..." + return m, m.createWorkflow() + } + return m, nil +} + +// ─── API ────────────────────────────────────────────────────────────────────── + +type workflowInstancesLoadedMsg struct { + instances []map[string]interface{} + err error +} + +type workflowCreatedMsg struct { + name string + err error +} + +func (m Model) fetchWorkflowInstances() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return workflowInstancesLoadedMsg{err: fmt.Errorf("no cloud project selected")} + } + var instances []map[string]interface{} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/instance", m.cloudProject) + if err := httpLib.Client.Get(endpoint, &instances); err != nil { + return workflowInstancesLoadedMsg{err: err} + } + return workflowInstancesLoadedMsg{instances: instances} + } +} + +func (m Model) createWorkflow() tea.Cmd { + return func() tea.Msg { + if m.cloudProject == "" { + return workflowCreatedMsg{err: fmt.Errorf("no cloud project selected")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/workflow/backup", + m.cloudProject, url.PathEscape(m.wizard.wfRegion)) + body := map[string]interface{}{ + "name": m.wizard.wfName, + "instanceId": m.wizard.wfInstanceId, + "cron": m.wizard.wfCron, + "rotation": m.wizard.wfRotation, + } + var result map[string]interface{} + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + return workflowCreatedMsg{err: err} + } + return workflowCreatedMsg{name: m.wizard.wfName} + } +} From e609be3edcd70d69326392c8fa2b46df13c6048a Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 11 May 2026 08:34:35 +0000 Subject: [PATCH 54/55] feat(compute): added settings of workflow Signed-off-by: olivier dubo --- internal/services/browser/api.go | 24 ++++++ internal/services/browser/manager.go | 105 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index bb40452b..4839ea56 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1334,6 +1334,30 @@ func (m Model) executeLBDelete() tea.Cmd { } } +// executeWorkflowDelete deletes the currently selected backup workflow. +func (m Model) executeWorkflowDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return workflowDeletedMsg{err: fmt.Errorf("aucun workflow sélectionné")} + } + if m.cloudProject == "" { + return workflowDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + wfID := getString(m.detailData, "id") + wfName := getString(m.detailData, "name") + region := getString(m.detailData, "region") + if wfID == "" || region == "" { + return workflowDeletedMsg{err: fmt.Errorf("ID ou région du workflow introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/workflow/backup/%s", + m.cloudProject, url.PathEscape(region), url.PathEscape(wfID)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return workflowDeletedMsg{name: wfName, err: fmt.Errorf("échec de la suppression: %w", err)} + } + return workflowDeletedMsg{name: wfName} + } +} + // executePrivNetworkDelete deletes the currently selected private network. func (m Model) executePrivNetworkDelete() tea.Cmd { return func() tea.Msg { diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index 303ff21d..b807f676 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -1036,6 +1036,11 @@ type fipDeletedMsg struct { err error } +type workflowDeletedMsg struct { + name string + err error +} + type fipDetachedMsg struct { fipIP string err error @@ -1893,6 +1898,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case workflowDeletedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Workflow \"%s\" supprimé avec succès", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/instances/workflow"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case fipCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -5647,11 +5668,77 @@ func (m Model) renderDetailView(width int) string { return m.objectDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductWorkflow: + return m.renderWorkflowDetail(width) default: return m.renderGenericDetail(width) } } +func (m Model) renderWorkflowDetail(width int) string { + var content strings.Builder + + name := getStringValue(m.detailData, "name", "N/A") + id := getStringValue(m.detailData, "id", "N/A") + region := getStringValue(m.detailData, "region", "N/A") + instanceId := getStringValue(m.detailData, "instanceId", "N/A") + cron := getStringValue(m.detailData, "cron", "N/A") + rotation := int(getFloatValue(m.detailData, "rotation", 0)) + lastStatus := getStringValue(m.detailData, "lastExecutionStatus", "-") + + labelSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22) + valueSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := width - 4 + + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + statusIcon := "⏳" + switch strings.ToLower(lastStatus) { + case "success": + statusIcon = "✅" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + case "error", "failed": + statusIcon = "❌" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + case "running": + statusIcon = "🔄" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + } + + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(id, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Région"), valueSt.Render(region))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Instance"), valueSt.Render(truncate(instanceId, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Cron"), valueSt.Render(cron))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Rotation"), valueSt.Render(fmt.Sprintf("%d sauvegardes", rotation)))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Statut"), statusStyle.Render(statusIcon+" "+lastStatus))) + infoBox := renderBox("Workflow : "+name, infoContent.String(), boxWidth) + + actions := []string{"Supprimer"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#FF6B6B")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Échap pour annuler", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (Enter pour exécuter, Échap pour retour)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(infoBox) + return content.String() +} + func (m Model) renderInstanceDetail(width int) string { var content strings.Builder @@ -6692,6 +6779,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Workflow, only 1 action (Delete) + if m.mode == DetailView && m.currentProduct == ProductWorkflow { + return m, nil + } // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { if m.selectedAction > 0 { @@ -6789,6 +6880,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Workflow, only 1 action (Delete) + if m.mode == DetailView && m.currentProduct == ProductWorkflow { + return m, nil + } // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { fipAttached := false @@ -7102,6 +7197,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true } return m, nil + } else if m.mode == DetailView && m.currentProduct == ProductWorkflow { + switch m.selectedAction { + case 0: // Supprimer + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeWorkflowDelete() + } + m.actionConfirm = true + } + return m, nil } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction { From 719174e53db6d687b0a75e1dbd519195f1f74092 Mon Sep 17 00:00:00 2001 From: olivier dubo Date: Mon, 11 May 2026 08:59:46 +0000 Subject: [PATCH 55/55] feat(compute): fixed details for instances baclup Signed-off-by: olivier dubo --- internal/services/browser/api.go | 45 ++++++++- internal/services/browser/manager.go | 135 ++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 6 deletions(-) diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 4839ea56..761c5398 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -1334,6 +1334,28 @@ func (m Model) executeLBDelete() tea.Cmd { } } +// executeInstanceBackupDelete deletes the currently selected instance snapshot/backup. +func (m Model) executeInstanceBackupDelete() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return instanceBackupDeletedMsg{err: fmt.Errorf("aucun backup sélectionné")} + } + if m.cloudProject == "" { + return instanceBackupDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + snapshotID := getString(m.detailData, "id") + snapshotName := getString(m.detailData, "name") + if snapshotID == "" { + return instanceBackupDeletedMsg{err: fmt.Errorf("ID du backup introuvable")} + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/snapshot/%s", m.cloudProject, url.PathEscape(snapshotID)) + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + return instanceBackupDeletedMsg{name: snapshotName, err: fmt.Errorf("échec de la suppression: %w", err)} + } + return instanceBackupDeletedMsg{name: snapshotName} + } +} + // executeWorkflowDelete deletes the currently selected backup workflow. func (m Model) executeWorkflowDelete() tea.Cmd { return func() tea.Msg { @@ -4411,7 +4433,7 @@ func (m Model) executeInstanceAction(actionIndex int) tea.Cmd { return instanceActionMsg{err: fmt.Errorf("instance ID not found")} } - actions := []string{"ssh", "reboot", "rescue", "stop_or_start", "vnc", "reinstall", "backup"} + actions := []string{"ssh", "reboot", "rescue", "stop_or_start", "vnc", "reinstall", "backup", "delete"} if actionIndex < 0 || actionIndex >= len(actions) { return instanceActionMsg{err: fmt.Errorf("invalid action index")} } @@ -4548,6 +4570,14 @@ func (m Model) executeInstanceAction(actionIndex int) tea.Cmd { if err == nil { return instanceActionMsg{action: "backup", instanceId: instanceId, backupName: snapshotName, err: nil} } + + case "delete": + // DELETE /cloud/project/{serviceName}/instance/{instanceId} + endpoint := fmt.Sprintf("/v1/cloud/project/%s/instance/%s", m.cloudProject, instanceId) + err = httpLib.Client.Delete(endpoint, nil) + if err == nil { + return instanceActionMsg{action: "delete", instanceId: instanceId, err: nil} + } } return instanceActionMsg{ @@ -4570,6 +4600,7 @@ func (m Model) handleInstanceAction(msg instanceActionMsg) (tea.Model, tea.Cmd) "vnc": "Console", "reinstall": "Reinstall", "backup": "Instance Backup", + "delete": "Delete", } actionName := actionNames[msg.action] @@ -4605,6 +4636,18 @@ func (m Model) handleInstanceAction(msg instanceActionMsg) (tea.Model, tea.Cmd) }) } + // For delete: go back to list, don't restore detail view + if msg.action == "delete" { + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/instances"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) + } + // Stay on detail view after action: set refresh IDs so handleDataLoaded returns to detail if m.detailData != nil { m.detailRefreshId = getString(m.detailData, "id") diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index b807f676..faa89475 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -1041,6 +1041,11 @@ type workflowDeletedMsg struct { err error } +type instanceBackupDeletedMsg struct { + name string + err error +} + type fipDetachedMsg struct { fipIP string err error @@ -1914,6 +1919,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) + case instanceBackupDeletedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Erreur: %s", msg.err.Error()) + m.notificationExpiry = time.Now().Add(8 * time.Second) + m.mode = TableView + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }) + } + m.notification = fmt.Sprintf("✅ Backup \"%s\" supprimé avec succès", msg.name) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil + m.mode = LoadingView + return m, tea.Batch( + m.fetchDataForPath("/instances/backup"), + tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), + ) + case fipCreatedMsg: m.wizard = WizardData{} if msg.err != nil { @@ -5670,6 +5691,8 @@ func (m Model) renderDetailView(width int) string { return m.renderGenericDetail(width) case ProductWorkflow: return m.renderWorkflowDetail(width) + case ProductInstanceBackup: + return m.renderInstanceBackupDetail(width) default: return m.renderGenericDetail(width) } @@ -5739,6 +5762,86 @@ func (m Model) renderWorkflowDetail(width int) string { return content.String() } +func (m Model) renderInstanceBackupDetail(width int) string { + var content strings.Builder + + name := getStringValue(m.detailData, "name", "N/A") + id := getStringValue(m.detailData, "id", "N/A") + status := getStringValue(m.detailData, "status", "N/A") + created := getStringValue(m.detailData, "creationDate", "-") + if len(created) >= 16 { + created = created[:16] + } + minDisk := int(getFloatValue(m.detailData, "minDisk", 0)) + sizeStr := "-" + if minDisk > 0 { + sizeStr = fmt.Sprintf("%d GB", minDisk) + } + location := getStringValue(m.detailData, "region", "") + if location == "" { + if regions, ok := m.detailData["regions"].([]interface{}); ok && len(regions) > 0 { + var rnames []string + for _, r := range regions { + if rs, ok := r.(string); ok { + rnames = append(rnames, rs) + } + } + location = strings.Join(rnames, ", ") + } + } + if location == "" { + location = "-" + } + + labelSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(20) + valueSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + boxWidth := width - 4 + + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + statusIcon := "🟡" + switch strings.ToLower(status) { + case "active": + statusIcon = "🟢" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")) + case "error": + statusIcon = "🔴" + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + } + + var infoContent strings.Builder + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("ID"), valueSt.Render(truncate(id, 36)))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Localisation"), valueSt.Render(location))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Taille disque"), valueSt.Render(sizeStr))) + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelSt.Render("Créé le"), valueSt.Render(created))) + infoContent.WriteString(fmt.Sprintf("%s %s", labelSt.Render("Statut"), statusStyle.Render(statusIcon+" "+status))) + infoBox := renderBox("Instance Backup : "+name, infoContent.String(), boxWidth) + + actions := []string{"Supprimer"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#FF4444")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true).Padding(0, 1).Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")).Bold(true). + Render(fmt.Sprintf("⚠️ Appuyez sur Enter pour confirmer %s, Échap pour annuler", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (Enter pour exécuter, Échap pour retour)", actionsContent, width-4) + + content.WriteString(actionsBox + "\n\n") + content.WriteString(infoBox) + return content.String() +} + func (m Model) renderInstanceDetail(width int) string { var content strings.Builder @@ -5862,13 +5965,17 @@ func (m Model) renderInstanceDetail(width int) string { if strings.ToUpper(status) == "RESCUE" { rescueAction = "Exit Rescue" } - actions := []string{"SSH", "Reboot", rescueAction, stopStartAction, "Console", "Reinstall", "Backup"} + actions := []string{"SSH", "Reboot", rescueAction, stopStartAction, "Console", "Reinstall", "Backup", "Delete"} var actionParts []string for i, action := range actions { if i == m.selectedAction { - // Selected action - highlighted + // Selected action - highlighted (Delete uses red) + bg := lipgloss.Color("#7B68EE") + if action == "Delete" { + bg = lipgloss.Color("#FF4444") + } actionParts = append(actionParts, lipgloss.NewStyle(). - Background(lipgloss.Color("#7B68EE")). + Background(bg). Foreground(lipgloss.Color("#FFFFFF")). Bold(true). Padding(0, 1). @@ -6783,6 +6890,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.mode == DetailView && m.currentProduct == ProductWorkflow { return m, nil } + // In DetailView for Instance Backup, only 1 action (Delete) + if m.mode == DetailView && m.currentProduct == ProductInstanceBackup { + return m, nil + } // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { if m.selectedAction > 0 { @@ -6858,7 +6969,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // In DetailView, navigate actions if m.mode == DetailView && m.currentProduct == ProductInstances { - if m.selectedAction < 6 { // 7 actions: 0-6 + if m.selectedAction < 7 { // 8 actions: 0-7 m.selectedAction++ m.actionConfirm = false } @@ -6884,6 +6995,10 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.mode == DetailView && m.currentProduct == ProductWorkflow { return m, nil } + // In DetailView for Instance Backup, only 1 action (Delete) + if m.mode == DetailView && m.currentProduct == ProductInstanceBackup { + return m, nil + } // In DetailView for Floating IPs, navigate actions (0=Delete, 1=Detach) if m.mode == DetailView && m.currentProduct == ProductNetworkPublic { fipAttached := false @@ -7197,7 +7312,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true } return m, nil - } else if m.mode == DetailView && m.currentProduct == ProductWorkflow { + } else if m.mode == DetailView && m.currentProduct == ProductWorkflow { switch m.selectedAction { case 0: // Supprimer if m.actionConfirm { @@ -7207,6 +7322,16 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true } return m, nil + } else if m.mode == DetailView && m.currentProduct == ProductInstanceBackup { + switch m.selectedAction { + case 0: // Supprimer + if m.actionConfirm { + m.actionConfirm = false + return m, m.executeInstanceBackupDelete() + } + m.actionConfirm = true + } + return m, nil } else if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { // Private Network detail actions switch m.selectedAction {