Skip to content

Commit 1916f58

Browse files
committed
feat(browser): added details in private network and fix create for private network
Signed-off-by: olivier dubo <olivier.dubo@ovhcloud.com>
1 parent 0f02005 commit 1916f58

4 files changed

Lines changed: 561 additions & 26 deletions

File tree

internal/services/browser/api.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,57 @@ func (m Model) handleVolumeActionDone(msg volumeActionDoneMsg) (tea.Model, tea.C
12741274
)
12751275
}
12761276

1277+
// executePrivNetworkDelete deletes the currently selected private network.
1278+
func (m Model) executePrivNetworkDelete() tea.Cmd {
1279+
return func() tea.Msg {
1280+
if m.detailData == nil {
1281+
return privNetDeletedMsg{err: fmt.Errorf("aucun réseau sélectionné")}
1282+
}
1283+
networkName := getString(m.detailData, "name")
1284+
if m.cloudProject == "" {
1285+
return privNetDeletedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")}
1286+
}
1287+
1288+
// The region-based API uses openstackId, not the vRack pn-XXXXX_N id.
1289+
// Each network has regions[].{region, openstackId} — delete from all regions.
1290+
regions, ok := m.detailData["regions"].([]interface{})
1291+
if !ok || len(regions) == 0 {
1292+
return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf("aucune région trouvée pour ce réseau")}
1293+
}
1294+
1295+
var lastErr error
1296+
for _, r := range regions {
1297+
rm, ok := r.(map[string]interface{})
1298+
if !ok {
1299+
continue
1300+
}
1301+
region := getString(rm, "region")
1302+
openstackID := getString(rm, "openstackId")
1303+
if region == "" || openstackID == "" {
1304+
continue
1305+
}
1306+
endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/network/%s",
1307+
m.cloudProject,
1308+
url.PathEscape(region),
1309+
url.PathEscape(openstackID),
1310+
)
1311+
if err := httpLib.Client.Delete(endpoint, nil); err != nil {
1312+
errMsg := err.Error()
1313+
if strings.Contains(errMsg, "409") || strings.Contains(errMsg, "Conflict") || strings.Contains(errMsg, "ports still in use") || strings.Contains(errMsg, "ports") {
1314+
return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf(
1315+
"impossible de supprimer le réseau : des ressources y sont encore attachées (instances, gateway, routeur). Détachez-les d'abord puis réessayez",
1316+
)}
1317+
}
1318+
lastErr = err
1319+
}
1320+
}
1321+
if lastErr != nil {
1322+
return privNetDeletedMsg{networkName: networkName, err: fmt.Errorf("failed to delete network: %w", lastErr)}
1323+
}
1324+
return privNetDeletedMsg{networkName: networkName}
1325+
}
1326+
}
1327+
12771328
// fetchPrivateNetworksData fetches private networks and enriches each with subnet details
12781329
func (m Model) fetchPrivateNetworksData() dataLoadedMsg {
12791330
if m.cloudProject == "" {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//go:build !(js && wasm)
6+
7+
package browser
8+
9+
import (
10+
"fmt"
11+
"net/url"
12+
"strings"
13+
14+
tea "github.com/charmbracelet/bubbletea"
15+
"github.com/charmbracelet/lipgloss"
16+
httpLib "github.com/ovh/ovhcloud-cli/internal/http"
17+
)
18+
19+
// gatewayModels lists the available OVHcloud gateway sizes.
20+
var gatewayModels = []string{"s", "m", "l", "xl", "2xl", "3xl"}
21+
22+
// ─── API call ─────────────────────────────────────────────────────────────────
23+
24+
// createGatewayFromWizard sends the POST request to create a gateway linked to the
25+
// private network/subnet stored in the wizard state.
26+
func (m Model) createGatewayFromWizard() tea.Cmd {
27+
return func() tea.Msg {
28+
if m.cloudProject == "" {
29+
return gwCreatedMsg{err: fmt.Errorf("aucun projet cloud sélectionné")}
30+
}
31+
32+
model := gatewayModels[m.wizard.gwModelIdx]
33+
body := map[string]interface{}{
34+
"model": model,
35+
"name": m.wizard.gwName,
36+
}
37+
38+
endpoint := fmt.Sprintf(
39+
"/v1/cloud/project/%s/region/%s/network/%s/subnet/%s/gateway",
40+
m.cloudProject,
41+
url.PathEscape(m.wizard.gwRegion),
42+
url.PathEscape(m.wizard.gwNetworkID),
43+
url.PathEscape(m.wizard.gwSubnetID),
44+
)
45+
46+
var result map[string]interface{}
47+
if err := httpLib.Client.Post(endpoint, body, &result); err != nil {
48+
return gwCreatedMsg{err: fmt.Errorf("failed to create gateway: %w", err)}
49+
}
50+
return gwCreatedMsg{gateway: result}
51+
}
52+
}
53+
54+
// ─── Render functions ─────────────────────────────────────────────────────────
55+
56+
func (m Model) renderGwWizardModelStep(width int) string {
57+
var content strings.Builder
58+
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
59+
selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF7F")).Padding(0, 1)
60+
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
61+
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
62+
63+
content.WriteString(titleStyle.Render("Choisir le modèle de la Gateway :") + "\n\n")
64+
content.WriteString(descStyle.Render(
65+
fmt.Sprintf("Réseau : %s • Région : %s", m.wizard.gwNetworkName, m.wizard.gwRegion),
66+
) + "\n\n")
67+
68+
for i, model := range gatewayModels {
69+
if i == m.wizard.gwModelIdx {
70+
content.WriteString(selectedStyle.Render("▶ " + strings.ToUpper(model)) + "\n")
71+
} else {
72+
content.WriteString(dimStyle.Render(" "+strings.ToUpper(model)) + "\n")
73+
}
74+
}
75+
76+
content.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).
77+
Render("↑↓ Naviguer • Enter : Sélectionner • Esc : Annuler"))
78+
return content.String()
79+
}
80+
81+
func (m Model) renderGwWizardNameStep(width int) string {
82+
var content strings.Builder
83+
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
84+
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
85+
86+
content.WriteString(titleStyle.Render("Nom de la Gateway :") + "\n\n")
87+
content.WriteString(descStyle.Render(
88+
fmt.Sprintf("Modèle : %s • Réseau : %s", strings.ToUpper(gatewayModels[m.wizard.gwModelIdx]), m.wizard.gwNetworkName),
89+
) + "\n\n")
90+
91+
if m.wizard.errorMsg != "" {
92+
content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n")
93+
}
94+
95+
inputStyle := lipgloss.NewStyle().
96+
Border(lipgloss.RoundedBorder()).
97+
BorderForeground(lipgloss.Color("#00FF7F")).
98+
Padding(0, 1).Width(40)
99+
content.WriteString(inputStyle.Render(m.wizard.gwNameInput+"▌") + "\n\n")
100+
content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).
101+
Render("Tapez le nom • Enter : Continuer • ← : Retour • Esc : Annuler"))
102+
return content.String()
103+
}
104+
105+
func (m Model) renderGwWizardConfirmStep(width int) string {
106+
var content strings.Builder
107+
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
108+
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Width(22)
109+
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
110+
111+
content.WriteString(titleStyle.Render("Confirmer la création de la Gateway :") + "\n\n")
112+
content.WriteString(labelStyle.Render(" Réseau :") + valueStyle.Render(m.wizard.gwNetworkName) + "\n")
113+
content.WriteString(labelStyle.Render(" Région :") + valueStyle.Render(m.wizard.gwRegion) + "\n")
114+
content.WriteString(labelStyle.Render(" Modèle :") + valueStyle.Render(strings.ToUpper(gatewayModels[m.wizard.gwModelIdx])) + "\n")
115+
content.WriteString(labelStyle.Render(" Nom :") + valueStyle.Render(m.wizard.gwName) + "\n")
116+
content.WriteString("\n")
117+
118+
if m.wizard.isLoading {
119+
content.WriteString(loadingStyle.Render("⏳ Création en cours..."))
120+
return content.String()
121+
}
122+
if m.wizard.errorMsg != "" {
123+
content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("Erreur : "+m.wizard.errorMsg) + "\n\n")
124+
}
125+
126+
btnCreate := lipgloss.NewStyle().Background(lipgloss.Color("#00FF7F")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Créer ")
127+
btnCancel := lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Annuler ")
128+
if m.wizard.gwConfirmBtnIdx == 1 {
129+
btnCreate = lipgloss.NewStyle().Background(lipgloss.Color("#333333")).Foreground(lipgloss.Color("#CCCCCC")).Padding(0, 2).Render(" Créer ")
130+
btnCancel = lipgloss.NewStyle().Background(lipgloss.Color("#FF6B6B")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" Annuler ")
131+
}
132+
content.WriteString(btnCreate + " " + btnCancel + "\n\n")
133+
content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).
134+
Render("←→ : Sélectionner • Enter : Confirmer • Esc : Annuler"))
135+
return content.String()
136+
}
137+
138+
// ─── Key handlers ─────────────────────────────────────────────────────────────
139+
140+
func (m Model) handleGwWizardModelKeys(key string) (tea.Model, tea.Cmd) {
141+
switch key {
142+
case "up", "k":
143+
if m.wizard.gwModelIdx > 0 {
144+
m.wizard.gwModelIdx--
145+
}
146+
case "down", "j":
147+
if m.wizard.gwModelIdx < len(gatewayModels)-1 {
148+
m.wizard.gwModelIdx++
149+
}
150+
case "enter":
151+
m.wizard.step = GwWizardStepName
152+
}
153+
return m, nil
154+
}
155+
156+
func (m Model) handleGwWizardNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
157+
key := msg.String()
158+
switch key {
159+
case "enter":
160+
name := strings.TrimSpace(m.wizard.gwNameInput)
161+
if name == "" {
162+
m.wizard.errorMsg = "Le nom ne peut pas être vide"
163+
return m, nil
164+
}
165+
m.wizard.gwName = name
166+
m.wizard.errorMsg = ""
167+
m.wizard.step = GwWizardStepConfirm
168+
case "left":
169+
m.wizard.step = GwWizardStepModel
170+
case "backspace":
171+
if len(m.wizard.gwNameInput) > 0 {
172+
m.wizard.gwNameInput = m.wizard.gwNameInput[:len(m.wizard.gwNameInput)-1]
173+
}
174+
default:
175+
if len(msg.Runes) > 0 {
176+
m.wizard.gwNameInput += string(msg.Runes)
177+
}
178+
}
179+
return m, nil
180+
}
181+
182+
func (m Model) handleGwWizardConfirmKeys(key string) (tea.Model, tea.Cmd) {
183+
switch key {
184+
case "left", "h":
185+
m.wizard.gwConfirmBtnIdx = 0
186+
case "right", "l":
187+
m.wizard.gwConfirmBtnIdx = 1
188+
case "enter":
189+
if m.wizard.gwConfirmBtnIdx == 1 {
190+
// Cancel → go back to name step
191+
m.wizard.step = GwWizardStepName
192+
return m, nil
193+
}
194+
m.wizard.isLoading = true
195+
m.wizard.loadingMessage = "Création de la Gateway..."
196+
return m, m.createGatewayFromWizard()
197+
}
198+
return m, nil
199+
}

0 commit comments

Comments
 (0)