diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 1ca2f7cd..761c5398 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -129,6 +129,18 @@ 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 "/instances/workflow": + return func() tea.Msg { + msg := m.fetchWorkflowsData() + msg.forProduct = product + return msg + } case "/storage/snapshot": return func() tea.Msg { msg := m.fetchVolumeSnapshotsData() @@ -1274,6 +1286,151 @@ 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} + } +} + +// 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} + } +} + +// 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 { + 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 { + 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 == "" { @@ -1329,6 +1486,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, @@ -1463,7 +1673,331 @@ 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 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é")} + } + 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)) + + // First attempt: without gateway (subnet already has one) + var result map[string]interface{} + 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) 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) 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} + } +} + +// 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) + 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 { + 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 @@ -1539,6 +2073,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} } @@ -1672,6 +2226,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() @@ -1765,28 +2324,24 @@ 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 ProductWorkflow: + m.table = createWorkflowsTable(msg.data, 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) 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: + 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) @@ -2008,6 +2563,180 @@ 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 +} + +// 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 @@ -2158,8 +2887,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 @@ -2193,6 +2924,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 != "" { @@ -2208,8 +2940,21 @@ 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 + } + } + } + } + gwName := "-" + if v := getString(net, "_gatewayName"); v != "" { + gwName = v } - rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp}) + rows = append(rows, table.Row{vlanId, name, location, cidr, gateway, dhcp, allocPool, gwName}) } tableHeight := height - 15 @@ -2261,7 +3006,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") @@ -2462,6 +3210,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{ @@ -2548,6 +3352,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 // ============================================ @@ -3584,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"} + 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")} } @@ -3710,6 +4559,25 @@ 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} + } + + 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{ @@ -3731,6 +4599,8 @@ func (m Model) handleInstanceAction(msg instanceActionMsg) (tea.Model, tea.Cmd) "start": "Start", "vnc": "Console", "reinstall": "Reinstall", + "backup": "Instance Backup", + "delete": "Delete", } actionName := actionNames[msg.action] @@ -3751,6 +4621,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) } @@ -3764,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") @@ -4735,6 +5619,67 @@ 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} +} + +// 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/floating_ip_wizard.go b/internal/services/browser/floating_ip_wizard.go new file mode 100644 index 00000000..c3fc5479 --- /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("Choose a region for the Floating IP:") + "\n\n") + + if m.wizard.isLoading { + content.WriteString(loadingStyle.Render("⏳ Loading regions...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + if len(m.wizard.fipAvailableRegions) == 0 { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + Render("No regions available.") + "\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("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("⏳ Loading instances...")) + return content.String() + } + if m.wizard.errorMsg != "" { + content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")). + Render("Error: "+m.wizard.errorMsg) + "\n\n") + } + + // 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) + } + + 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("↑↓ Navigate • Enter: Select • ←: Back • Esc: Cancel")) + 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("Confirm Floating IP creation:") + "\n\n") + content.WriteString(lbLabelStyle.Render(" Region:") + valStyle.Render(m.wizard.fipRegion) + "\n") + + instanceDisplay := "None (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("⏳ Creating...")) + return content.String() + } + if m.wizard.errorMsg != "" { + 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 ") + 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(" 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("←→: Select • Enter: Confirm • Esc: Cancel")) + 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 = "Loading 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 = "Creating Floating IP..." + return m, m.createStandaloneFloatingIP() + } + return m, nil +} diff --git a/internal/services/browser/gateway_wizard.go b/internal/services/browser/gateway_wizard.go new file mode 100644 index 00000000..3e22be75 --- /dev/null +++ b/internal/services/browser/gateway_wizard.go @@ -0,0 +1,463 @@ +// 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" +) + +// 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 ────────────────────────────────────────────────────────────── + +// 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 == "" { + return gwCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")} + } + + 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, + } + + 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} + } +} + +// ─── 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")) + 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 de la Gateway :") + "\n\n") + content.WriteString(descStyle.Render(fmt.Sprintf("Région : %s", m.wizard.gwRegion)) + "\n\n") + + models := m.gwActiveModels() + for i, model := range models { + if i == m.wizard.gwModelIdx { + 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 • "+backHint+"• 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")) + + 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, modelName), + ) + "\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) 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, 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 { + 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")) + 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égion :") + valueStyle.Render(m.wizard.gwRegion) + "\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") + } + 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) 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.gwModelIdx = 0 + m.wizard.step = GwWizardStepModel + } + } + return m, nil +} + +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(models)-1 { + m.wizard.gwModelIdx++ + } + 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 +} + +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 = "" + // 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": + 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) 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": + m.wizard.gwConfirmBtnIdx = 0 + case "right", "l": + m.wizard.gwConfirmBtnIdx = 1 + case "enter": + if m.wizard.gwConfirmBtnIdx == 1 { + // 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 + m.wizard.loadingMessage = "Création de la Gateway..." + return m, m.createGatewayFromWizard() + } + return m, nil +} + diff --git a/internal/services/browser/lb_wizard.go b/internal/services/browser/lb_wizard.go new file mode 100644 index 00000000..d52a6d03 --- /dev/null +++ b/internal/services/browser/lb_wizard.go @@ -0,0 +1,485 @@ +// 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 { + if getStringValue(n, "visibility", "") == "private" { + 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 c60b15d2..faa89475 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -146,10 +146,44 @@ 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 ) +const ( + GwWizardStepRegion WizardStep = iota + 900 // select region + GwWizardStepModel // select model/size + GwWizardStepName // enter name + GwWizardStepNetwork // select private network + 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 +) + +const ( + // Floating IP wizard steps (offset by 1100) + FIPWizardStepRegion WizardStep = iota + 1100 // select region + FIPWizardStepInstance // select instance (optional) + 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 @@ -171,6 +205,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 @@ -224,6 +261,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 @@ -370,10 +408,77 @@ 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) 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) + 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 + 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 + 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 + + // 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 + + // 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 @@ -388,6 +493,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{} @@ -440,8 +547,13 @@ 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{} // S3 user creation result (for credentials display) s3CreatedUser map[string]interface{} s3CreatedCredentials map[string]interface{} @@ -549,10 +661,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 @@ -665,6 +778,7 @@ type progressMsg struct { type instanceActionMsg struct { action string instanceId string + backupName string err error } @@ -821,9 +935,135 @@ type privNetCreatedMsg struct { err error } +type subnetAddedMsg struct { + networkID string + err error +} + +type subnetDeletedMsg struct { + networkID string + err error +} + +type regionDeletedMsg struct { + networkID string + region string + err error +} + +type gatewayDetachedMsg struct { + networkID string + err error +} + +type privNetDeletedMsg struct { + networkName string + err error +} + +type gwCreatedMsg struct { + gateway map[string]interface{} + 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 +} + +type gwDeletedMsg struct { + gatewayName string + 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 +} + +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 +} + +type fipDeletedMsg struct { + fipIP string + err error +} + +type workflowDeletedMsg struct { + name string + err error +} + +type instanceBackupDeletedMsg struct { + name string + err error +} + +type fipDetachedMsg struct { + fipIP string + err error +} + +type subnetsLoadedMsg struct { + networkID string + subnets []map[string]any +} + +type privNetDetailLoadedMsg struct { + networkID string + regions []interface{} +} + 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"}, @@ -865,6 +1105,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: true}, + {Label: "Workflow", Product: ProductWorkflow, Path: "/instances/workflow", Enabled: true}, + } +} + // StartBrowser is the entry point for the browser TUI func StartBrowser(cmd *cobra.Command, args []string) { // Reset creation command @@ -1020,16 +1275,55 @@ 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: "192.168.0.0/24", + 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 { + m.mode = WizardView + m.wizard = WizardData{ + step: GwWizardStepRegion, + isLoading: true, + loadingMessage: "Loading regions...", + } + return m, m.fetchGwRegions() + } else if msg.product == ProductNetworkLB { + m.mode = WizardView + m.wizard = WizardData{ + step: LBWizardStepName, + } + return m, nil + } else if msg.product == ProductNetworkPublic { + m.mode = WizardView + m.wizard = WizardData{ + step: FIPWizardStepRegion, + isLoading: true, + 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() @@ -1205,80 +1499,515 @@ 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 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 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 { + 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 { + 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 volumeBackupCreatedMsg: + case gwDeletedMsg: m.wizard = WizardData{} if msg.err != nil { - m.notification = fmt.Sprintf("❌ Error: %s", msg.err.Error()) + 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' created successfully", label, msg.name) + m.notification = fmt.Sprintf("✅ Gateway '%s' deleted successfully", msg.gatewayName) m.notificationExpiry = time.Now().Add(5 * time.Second) + m.detailData = nil m.mode = LoadingView - reloadPath := "/storage/snapshot" - if msg.backupType == "backup" { - reloadPath = "/storage/backup" - } return m, tea.Batch( - m.fetchDataForPath(reloadPath), + m.fetchDataForPath("/networks/gateway"), tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return clearNotificationMsg{} }), ) - case file_storage.ExecuteFileShareActionMsg: - return m.handleExecuteFileShareAction(msg) - - case fileShareActionDoneMsg: - return m.handleFileShareActionDone(msg) - - case object_storage.ExecuteUserActionMsg: - return m.handleExecuteUserAction(msg) - - case s3SecretLoadedMsg: + case gwCreatedMsg: + attachMode := m.wizard.gwAttachMode + m.wizard = WizardData{} if msg.err != nil { - m.notification = fmt.Sprintf("❌ Failed to retrieve secret key: %s", msg.err.Error()) + 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{} }) } - if m.objectUserDetailView != nil { - m.objectUserDetailView.SetSecret(msg.secret) + 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"), + 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 s3UserActionDoneMsg: - return m.handleS3UserActionDone(msg) + 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 object_storage.ExecuteContainerActionMsg: - containerName := "" - region := "" - if msg.Container != nil { - if n, ok := msg.Container["name"].(string); ok { - containerName = n + 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 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{} }) + } + // 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 + return m, tea.Batch( + m.fetchDataForPath("/loadbalancer"), + 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 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()) + 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 { + 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 = "" + 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 + } + // 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 + + 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 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 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 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 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 { + 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 { + 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{} }) + } + label := "Snapshot" + if msg.backupType == "backup" { + label = "Backup" + } + 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" + 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) + + case fileShareActionDoneMsg: + return m.handleFileShareActionDone(msg) + + case object_storage.ExecuteUserActionMsg: + return m.handleExecuteUserAction(msg) + + case s3SecretLoadedMsg: + if msg.err != nil { + 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{} }) + } + if m.objectUserDetailView != nil { + m.objectUserDetailView.SetSecret(msg.secret) + } + return m, nil + + case s3UserActionDoneMsg: + return m.handleS3UserActionDone(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 @@ -1738,7 +2467,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 { @@ -1776,6 +2505,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 } @@ -1898,29 +2635,99 @@ func (m Model) renderNetworkSubNav(width int) string { return subBarStyle.Width(width - 2).Render(subContent) } -func (m Model) renderContentBox(width int) string { - var titleText string +func (m Model) renderComputeSubNav(width int) string { + subItems := getComputeSubItems() + var items []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 { - // 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 { - // 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 { - // Volume wizard - titleText = " 💾 Create Volume " - } else if m.wizard.step >= 200 { - // Node pool wizard + 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 + + // Handle wizard mode with special title + if m.mode == WizardView { + // Determine which wizard we're in based on the step + 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 { + // 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 { + // 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 { + // 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 { + // File Storage wizard + titleText = " 🗂️ Create File Share " + } else 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 { // Kubernetes wizard @@ -2854,10 +3661,31 @@ 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 >= 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) + } else if m.wizard.step >= 1000 { + // Load Balancer wizard + 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, "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") - stepMapping = append(stepMapping, PrivNetWizardStepRegion, PrivNetWizardStepName, PrivNetWizardStepVlanID, PrivNetWizardStepSubnet, PrivNetWizardStepDHCP, 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") @@ -3053,10 +3881,52 @@ 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: 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)) + // 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)) + // 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)) + // 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)) @@ -4639,7 +5509,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: @@ -4721,31 +5591,11 @@ 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) + return tableContent +} - 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) renderPublicIPsWithTabs(tableContent string, width int) string { + return tableContent } func (m Model) renderDeleteConfirmView() string { @@ -4823,6 +5673,14 @@ func (m Model) renderDetailView(width int) string { return m.fileShareDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductNetworkPrivate: + return m.renderPrivateNetworkDetail(width) + case ProductNetworkGateway: + 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) @@ -4831,11 +5689,159 @@ func (m Model) renderDetailView(width int) string { return m.objectDetailView.Render(width, 0) } return m.renderGenericDetail(width) + case ProductWorkflow: + return m.renderWorkflowDetail(width) + case ProductInstanceBackup: + return m.renderInstanceBackupDetail(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) 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 @@ -4939,61 +5945,453 @@ func (m Model) renderInstanceDetail(width int) string { if ipv4Private != "" { networkContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("IPv4 Private"), valueStyle.Render(ipv4Private))) } - // Show IPv6 - if ipv6Public != "" { - networkContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IPv6"), valueStyle.Render(truncate(ipv6Public, 35)))) - } else { - networkContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IPv6"), valueStyle.Render("N/A"))) + // Show IPv6 + if ipv6Public != "" { + networkContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IPv6"), valueStyle.Render(truncate(ipv6Public, 35)))) + } else { + networkContent.WriteString(fmt.Sprintf("%s %s", labelStyle.Render("IPv6"), valueStyle.Render("N/A"))) + } + + networkBox := renderBox("Network", networkContent.String(), boxWidth) + + // Actions box (top) with selectable actions + // Change Stop to Start if instance is SHUTOFF + stopStartAction := "Stop" + if strings.ToUpper(status) == "SHUTOFF" { + stopStartAction = "Start" + } + // Change Rescue Mode to Exit Rescue if instance is in RESCUE + rescueAction := "Rescue Mode" + if strings.ToUpper(status) == "RESCUE" { + rescueAction = "Exit Rescue" + } + 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 (Delete uses red) + bg := lipgloss.Color("#7B68EE") + if action == "Delete" { + bg = lipgloss.Color("#FF4444") + } + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(bg). + 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("Quick Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) + + // Combine everything + content.WriteString(actionsBox) + content.WriteString("\n\n") + + // Side by side boxes + leftRight := lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", networkBox) + content.WriteString(leftRight) + + 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("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("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{"Delete"} + 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(lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", netBox)) + 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("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("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{"Delete"} + 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(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) - networkBox := renderBox("Network", networkContent.String(), boxWidth) + content.WriteString(actionsBox + "\n\n") + content.WriteString(infoBox) + return content.String() +} - // Actions box (top) with selectable actions - // Change Stop to Start if instance is SHUTOFF - stopStartAction := "Stop" - if strings.ToUpper(status) == "SHUTOFF" { - stopStartAction = "Start" +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") + + 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" + if vlanID > 0 { vlanStr = fmt.Sprintf("%d", vlanID) } + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("VLAN ID"), valueStyle.Render(vlanStr))) + rTypeLabel := "Region (vRack)" + if regionType == "localzone" { rTypeLabel = "Local Zone" } + infoContent.WriteString(fmt.Sprintf("%s %s\n", labelStyle.Render("Type"), valueStyle.Render(rTypeLabel))) + // 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)) + } } - // Change Rescue Mode to Exit Rescue if instance is in RESCUE - rescueAction := "Rescue Mode" - if strings.ToUpper(status) == "RESCUE" { - rescueAction = "Exit Rescue" + infoBox := renderBox("Private Network", infoContent.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))) + 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)) + } } - actions := []string{"SSH", "Reboot", rescueAction, stopStartAction, "Console", "Reinstall"} + + _ = netName + + // Actions + 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 { - // Selected action - highlighted actionParts = append(actionParts, lipgloss.NewStyle(). Background(lipgloss.Color("#7B68EE")). Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 1). - Render(action)) + Bold(true).Padding(0, 1).Render(action)) } else { actionParts = append(actionParts, lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")). - Padding(0, 1). - Render("["+action+"]")) + Foreground(lipgloss.Color("#888888")).Padding(0, 1).Render("["+action+"]")) } } 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). + Foreground(lipgloss.Color("#FFD700")).Bold(true). Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) } - actionsBox := renderBox("Quick Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) - - // Combine everything - content.WriteString(actionsBox) - content.WriteString("\n\n") - - // Side by side boxes - leftRight := lipgloss.JoinHorizontal(lipgloss.Top, infoBox, " ", networkBox) - content.WriteString(leftRight) + 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") + for _, sb := range subnetBoxes { + content.WriteString(sb + "\n") + } return content.String() } @@ -5236,21 +6634,23 @@ 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 { - tabHint := "" - if m.currentProduct == ProductNetworkPrivate || m.currentProduct == ProductStorageObject { - tabHint = " • t: Switch Tab" + } 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 { + 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 { + } 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" @@ -5264,6 +6664,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 { @@ -5472,6 +6878,39 @@ 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 + } + // In DetailView for Workflow, only 1 action (Delete) + 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 { + m.selectedAction-- + m.actionConfirm = false + } + 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() @@ -5496,11 +6935,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() } } @@ -5517,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 < 5 { // 6 actions: 0-5 + if m.selectedAction < 7 { // 8 actions: 0-7 m.selectedAction++ m.actionConfirm = false } @@ -5531,6 +6983,45 @@ 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, 3=Delete Subnet) + if m.mode == DetailView && m.currentProduct == ProductNetworkPrivate { + if m.selectedAction < 5 { + m.selectedAction++ + m.actionConfirm = false + } + return m, nil + } + // In DetailView for Workflow, only 1 action (Delete) + 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 + 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 + } + // 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 { @@ -5557,40 +7048,32 @@ 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() } } 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": @@ -5625,7 +7108,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 } @@ -5638,6 +7121,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 { @@ -5674,6 +7161,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 @@ -5724,6 +7220,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 { @@ -5767,6 +7276,210 @@ 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 == 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 == 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 == 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 == 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 { + case 0: // Delete + if m.actionConfirm { + m.actionConfirm = false + return m, m.executePrivNetworkDelete() + } + m.actionConfirm = true + case 1: // Assign Gateway + m.actionConfirm = false + // 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, + } + } + } + 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{} }) + } + 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, + gwAttachMode: true, + } + return m, nil + } + // Multiple regions: let user choose + m.mode = WizardView + m.wizard = WizardData{ + step: GwWizardStepRegion, + gwNetworkName: netName, + gwAvailableRegions: regionNames, + gwNetworkRegionMap: regionMap, + 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() + 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 + 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 { // Select project and go to products view selectedRow := m.table.Cursor() @@ -5799,13 +7512,15 @@ 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) { 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} @@ -5848,6 +7563,15 @@ 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, tea.Batch( + m.fetchNetworkSubnets(netId), + m.fetchPrivateNetworkDetail(netId), + ) + } + } } } } @@ -5856,11 +7580,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 @@ -5872,6 +7597,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) @@ -5890,8 +7620,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 } @@ -5912,6 +7646,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 { @@ -6244,13 +8010,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.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.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 @@ -6268,7 +8034,18 @@ 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 >= 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 { + // 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" @@ -6441,10 +8218,52 @@ 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: 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) + // 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) + // Floating IP wizard steps + case FIPWizardStepRegion: + return m.handleFIPWizardRegionKeys(key) + case FIPWizardStepInstance: + 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 } @@ -7780,6 +9599,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) @@ -7794,10 +9614,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(), @@ -7831,6 +9657,24 @@ 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 + return m, nil + } + + m.mode = LoadingView + 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 @@ -7838,6 +9682,13 @@ func (m Model) loadNetworkSubProduct() (Model, tea.Cmd) { } 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) } diff --git a/internal/services/browser/private_network_wizard.go b/internal/services/browser/private_network_wizard.go index 64d83f9a..5d918b34 100644 --- a/internal/services/browser/private_network_wizard.go +++ b/internal/services/browser/private_network_wizard.go @@ -10,7 +10,9 @@ import ( "fmt" "net" "net/url" + "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -37,39 +39,140 @@ 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 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 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 == "" { + 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)} + } + + // 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 { + 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 + + 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{} + 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), } - 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) } } } @@ -168,6 +271,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") @@ -205,65 +319,97 @@ 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 ✓" + // 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(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(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") + // 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() } 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() } @@ -311,59 +457,71 @@ 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") - - vlanStr := "automatique" - 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 { - content.WriteString(labelStyle.Render(" Sous-réseau (CIDR) :") + valueStyle.Render(m.wizard.privNetCIDR) + "\n") - dhcpStr := "désactivé" + 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 { - 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 ") + 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(" 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(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") 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() } @@ -384,7 +542,13 @@ 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) - m.wizard.step = PrivNetWizardStepName + rtype, _ := r["type"].(string) + m.wizard.privNetIsLocalZone = (rtype == "localzone") + if m.wizard.privNetAddSubnetMode { + m.wizard.step = PrivNetWizardStepSubnet + } else { + m.wizard.step = PrivNetWizardStepName + } } } return m, nil @@ -443,11 +607,21 @@ 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 } 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 @@ -483,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] @@ -498,18 +676,105 @@ 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": - m.wizard.step = PrivNetWizardStepGateway + // 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.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 +} + func (m Model) handlePrivNetWizardGatewayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { @@ -532,8 +797,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] } @@ -553,20 +817,29 @@ 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 = PrivNetWizardStepAllocPool + } else { + m.wizard.step = PrivNetWizardStepGateway + } 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() } 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 +853,98 @@ 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 } + +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 + 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) + } + } + } + } + } + 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 and capture the openstackId + 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 + openstackID, _ = rm["openstackId"].(string) + } + } + } + } + } + if regionActive { + break + } + } + if !regionActive { + return subnetAddedMsg{networkID: netID, err: fmt.Errorf("region %s did not become ACTIVE in time", region)} + } + } + + 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{}{ + "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/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} + } + return subnetAddedMsg{networkID: netID} + } +} 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} + } +}