From c67ab37516b25db921727252d99e87fa288695a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Thu, 13 Nov 2025 16:15:49 +0100 Subject: [PATCH 01/20] feat(dedicated-cloud): Add datacenter management commands and enhanced display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add datacenter list and get commands - Display datacenter details with clusters, hosts, and filers information - Add cluster information with DRS/HA status indicators - Enhance host display with connection state, maintenance status, cores, RAM, VMs, billing - Enhance filer display with size, space free, cluster, connection state, visibility - Add totals (CPU cores, RAM, VMs, Disk Space) in both datacenter list and get views - Support for both local and global filers - Format version and location mapping for better readability - Use color indicators for connection and status states Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- internal/cmd/dedicatedcloud.go | 22 + .../services/dedicatedcloud/dedicatedcloud.go | 752 +++++++++++++++++- .../dedicatedcloud/templates/datacenter.tmpl | 71 ++ .../templates/dedicatedcloud.tmpl | 13 +- 4 files changed, 853 insertions(+), 5 deletions(-) create mode 100644 internal/services/dedicatedcloud/templates/datacenter.tmpl diff --git a/internal/cmd/dedicatedcloud.go b/internal/cmd/dedicatedcloud.go index 9d9baf39..fac81856 100644 --- a/internal/cmd/dedicatedcloud.go +++ b/internal/cmd/dedicatedcloud.go @@ -32,5 +32,27 @@ func init() { Run: dedicatedcloud.GetDedicatedCloud, }) + // Datacenter commands + dedicatedcloudDatacenterCmd := &cobra.Command{ + Use: "datacenter", + Short: "Manage datacenters of a DedicatedCloud", + } + dedicatedcloudCmd.AddCommand(dedicatedcloudDatacenterCmd) + + dedicatedcloudDatacenterCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List datacenters of a DedicatedCloud", + Args: cobra.ExactArgs(1), + Run: dedicatedcloud.ListDatacenter, + })) + + dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Get information about a specific datacenter of a DedicatedCloud", + Args: cobra.ExactArgs(2), + Run: dedicatedcloud.GetDatacenter, + }) + rootCmd.AddCommand(dedicatedcloudCmd) } diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 23ca2932..e59bc178 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -6,23 +6,767 @@ package dedicatedcloud import ( _ "embed" + "encoding/json" + "fmt" + "net/url" + "strings" + "github.com/ovh/ovhcloud-cli/internal/display" + "github.com/ovh/ovhcloud-cli/internal/filters" "github.com/ovh/ovhcloud-cli/internal/flags" - "github.com/ovh/ovhcloud-cli/internal/services/common" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" "github.com/spf13/cobra" ) var ( - dedicatedcloudColumnsToDisplay = []string{"serviceName", "location", "state", "description"} + dedicatedcloudColumnsToDisplay = []string{"serviceName", "regionLocation", "version", "state", "description"} //go:embed templates/dedicatedcloud.tmpl dedicatedcloudTemplate string + + //go:embed templates/datacenter.tmpl + datacenterTemplate string ) +// getLocationMap fetches the location mapping from the API +func getLocationMap() (map[string]string, error) { + // Fetch location list (array of location IDs) + var locationIDs []string + if err := httpLib.Client.Get("/dedicatedCloud/location", &locationIDs); err != nil { + return nil, fmt.Errorf("failed to fetch location list: %w", err) + } + + // Build location map by fetching each location detail + locationMap := make(map[string]string) + for _, locationID := range locationIDs { + var locationInfo map[string]any + locationPath := fmt.Sprintf("/dedicatedCloud/location/%s", url.PathEscape(locationID)) + if err := httpLib.Client.Get(locationPath, &locationInfo); err != nil { + // Skip if we can't fetch this location + continue + } + if regionLocation, ok := locationInfo["regionLocation"].(string); ok { + locationMap[locationID] = regionLocation + } + } + + return locationMap, nil +} + func ListDedicatedCloud(_ *cobra.Command, _ []string) { - common.ManageListRequest("/dedicatedCloud", "", dedicatedcloudColumnsToDisplay, flags.GenericFilters) + // Fetch dedicatedcloud list + body, err := httpLib.FetchExpandedArray("/dedicatedCloud", "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch results: %s", err) + return + } + + // Fetch location mapping + locationMap, err := getLocationMap() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // Enrich each object with version and regionLocation + for _, obj := range body { + // Format version + if versionObj, ok := obj["version"].(map[string]any); ok { + major := "" + minor := "" + build := "" + if v, ok := versionObj["major"].(string); ok { + major = v + } + if v, ok := versionObj["minor"].(string); ok { + minor = v + } + if v, ok := versionObj["build"].(string); ok { + build = v + } + versionStr := major + if minor != "" { + versionStr += "." + minor + } + if build != "" { + versionStr += "." + build + } + obj["version"] = versionStr + } + + // Add regionLocation from location mapping + if location, ok := obj["location"].(string); ok { + if regionLocation, ok := locationMap[location]; ok { + obj["regionLocation"] = regionLocation + } else { + obj["regionLocation"] = location + } + } + } + + // Filter results + body, err = filters.FilterLines(body, flags.GenericFilters) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err) + return + } + + // Display table + display.RenderTable(body, dedicatedcloudColumnsToDisplay, &flags.OutputFormatConfig) } func GetDedicatedCloud(_ *cobra.Command, args []string) { - common.ManageObjectRequest("/dedicatedCloud", args[0], dedicatedcloudTemplate) + endpoint := fmt.Sprintf("/dedicatedCloud/%s", url.PathEscape(args[0])) + + // Fetch dedicatedcloud + var object map[string]any + if err := httpLib.Client.Get(endpoint, &object); err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching %s: %s", endpoint, err) + return + } + + // Fetch location mapping and add regionLocation + locationMap, err := getLocationMap() + if err == nil { + if location, ok := object["location"].(string); ok { + if regionLocation, ok := locationMap[location]; ok { + object["regionLocation"] = regionLocation + } else { + object["regionLocation"] = location + } + } + } + + // Fetch datacenters list + datacentersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter", url.PathEscape(args[0])) + datacenters, err := httpLib.FetchExpandedArray(datacentersEndpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching datacenters for %s: %s", args[0], err) + return + } + object["datacenters"] = datacenters + + display.OutputObject(object, args[0], dedicatedcloudTemplate, &flags.OutputFormatConfig) +} + +func ListDatacenter(_ *cobra.Command, args []string) { + endpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter", url.PathEscape(args[0])) + datacenters, err := httpLib.FetchExpandedArray(endpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch results: %s", err) + return + } + + // Enrich each datacenter with totals + for i := range datacenters { + datacenterId := "" + if idRaw, ok := datacenters[i]["datacenterId"]; ok && idRaw != nil { + switch v := idRaw.(type) { + case json.Number: + datacenterId = v.String() + case float64: + datacenterId = fmt.Sprintf("%.0f", v) + case int: + datacenterId = fmt.Sprintf("%d", v) + case int64: + datacenterId = fmt.Sprintf("%d", v) + case string: + datacenterId = v + } + } + + if datacenterId == "" { + continue + } + + // Fetch hosts for this datacenter + hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(datacenterId)) + hosts, err := httpLib.FetchExpandedArray(hostsEndpoint, "") + if err != nil { + // If error, continue with empty totals + hosts = []map[string]any{} + } + + // Fetch filers for this datacenter + localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(datacenterId)) + localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "") + if err != nil { + localFilers = []map[string]any{} + } + + globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0])) + globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "") + if err != nil { + globalFilers = []map[string]any{} + } + allFilers := append(localFilers, globalFilers...) + + // Calculate totals + totalCores := 0 + totalRAM := 0.0 + totalVMs := 0 + totalDiskSpace := 0.0 + + // Sum from hosts + for _, host := range hosts { + // Sum cores + if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { + var cpuNumValue float64 + switch v := cpuNumRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + cpuNumValue = val + } + case float64: + cpuNumValue = v + case int: + cpuNumValue = float64(v) + case int64: + cpuNumValue = float64(v) + case int32: + cpuNumValue = float64(v) + } + totalCores += int(cpuNumValue) + } + + // Sum RAM + if ram, ok := host["ram"].(map[string]any); ok { + if ramValue, ok := ram["value"].(float64); ok { + totalRAM += ramValue + } else if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { + switch v := ramValueRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + totalRAM += val + } + case float64: + totalRAM += v + case int: + totalRAM += float64(v) + case int64: + totalRAM += float64(v) + } + } + } + + // Sum VMs + if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { + switch v := vmTotalRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + totalVMs += int(val) + } + case float64: + totalVMs += int(v) + case int: + totalVMs += v + case int64: + totalVMs += int(v) + case int32: + totalVMs += int(v) + } + } + } + + // Sum disk space from filers + for _, filer := range allFilers { + if size, ok := filer["size"].(map[string]any); ok { + var sizeValue float64 + if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { + switch v := sizeValueRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + sizeValue = val + } + case float64: + sizeValue = v + case int: + sizeValue = float64(v) + case int64: + sizeValue = float64(v) + case int32: + sizeValue = float64(v) + } + // Convert to GB if needed + if sizeUnit, ok := size["unit"].(string); ok { + if strings.ToUpper(sizeUnit) == "TB" { + sizeValue *= 1000 // Convert TB to GB + } else if strings.ToUpper(sizeUnit) == "MB" { + sizeValue /= 1000 // Convert MB to GB + } + } + totalDiskSpace += sizeValue + } + } + } + + // Add totals to datacenter + datacenters[i]["totalCores"] = totalCores + datacenters[i]["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM) + datacenters[i]["totalVMs"] = totalVMs + datacenters[i]["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace) + } + + // Filter results + datacenters, err = filters.FilterLines(datacenters, flags.GenericFilters) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err) + return + } + + // Display table + display.RenderTable(datacenters, []string{"datacenterId", "name", "version", "commercialName", "totalCores", "totalRAM", "totalVMs", "totalDiskSpace"}, &flags.OutputFormatConfig) +} + +func GetDatacenter(_ *cobra.Command, args []string) { + path := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s", url.PathEscape(args[0]), url.PathEscape(args[1])) + + // Fetch datacenter + var object map[string]any + if err := httpLib.Client.Get(path, &object); err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching %s: %s", path, err) + return + } + + // Fetch hosts list + hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(args[1])) + hosts, err := httpLib.FetchExpandedArray(hostsEndpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching hosts for %s: %s", args[1], err) + return + } + + // Enrich hosts with formatted data and group by cluster + hostsByCluster := make(map[string][]map[string]any) + for i := range hosts { + // Format Core Number with GHz in parentheses + coreNumber := "" + if cpuNumRaw, ok := hosts[i]["cpuNum"]; ok && cpuNumRaw != nil { + var cpuNumValue float64 + switch v := cpuNumRaw.(type) { + case json.Number: + var err error + cpuNumValue, err = v.Float64() + if err != nil { + cpuNumValue = 0 + } + case float64: + cpuNumValue = v + case int: + cpuNumValue = float64(v) + case int64: + cpuNumValue = float64(v) + case int32: + cpuNumValue = float64(v) + } + if cpuNumValue > 0 { + coreNumber = fmt.Sprintf("%.0f", cpuNumValue) + if cpu, ok := hosts[i]["cpu"].(map[string]any); ok { + var cpuValue float64 + switch cv := cpu["value"].(type) { + case json.Number: + var err error + cpuValue, err = cv.Float64() + if err != nil { + cpuValue = 0 + } + case float64: + cpuValue = cv + } + if cpuValue > 0 { + // Use format: cores (freqGHz) + coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue) + } + } + } + } + hosts[i]["coreNumber"] = coreNumber + + // Add VM count + vmCount := 0 + if vmTotalRaw, ok := hosts[i]["vmTotal"]; ok && vmTotalRaw != nil { + switch v := vmTotalRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + vmCount = int(val) + } + case float64: + vmCount = int(v) + case int: + vmCount = v + case int64: + vmCount = int(v) + case int32: + vmCount = int(v) + } + } + hosts[i]["vmCount"] = vmCount + + // Add maintenance status emoji + if inMaintenance, ok := hosts[i]["inMaintenance"].(bool); ok && inMaintenance { + hosts[i]["maintenanceStatus"] = "🔧" + } else { + hosts[i]["maintenanceStatus"] = "✅" + } + + // Add connection state indicator + connectionStateIndicator := "🔴" + if connectionState, ok := hosts[i]["connectionState"].(string); ok && connectionState == "connected" { + connectionStateIndicator = "🟢" + } + hosts[i]["connectionStateIndicator"] = connectionStateIndicator + + // Group by cluster + clusterName := "Unknown" + if cn, ok := hosts[i]["clusterName"].(string); ok && cn != "" { + clusterName = cn + } + hostsByCluster[clusterName] = append(hostsByCluster[clusterName], hosts[i]) + } + object["hostsByCluster"] = hostsByCluster + object["hosts"] = hosts // Keep original for backward compatibility + + // Fetch clusters information + clustersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster", url.PathEscape(args[0]), url.PathEscape(args[1])) + clustersList, err := httpLib.FetchExpandedArray(clustersEndpoint, "") + if err != nil { + // If error, just continue with empty list + clustersList = []map[string]any{} + } + + // Build a map of clusterName to clusterId from hosts + clusterNameToId := make(map[string]int) + for _, host := range hosts { + if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" { + if clusterIdRaw, ok := host["clusterId"]; ok && clusterIdRaw != nil { + var clusterId int + switch v := clusterIdRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + clusterId = int(val) + } + case float64: + clusterId = int(v) + case int: + clusterId = v + case int64: + clusterId = int(v) + } + if clusterId > 0 { + clusterNameToId[clusterName] = clusterId + } + } + } + } + + // Fetch details for each cluster that has hosts + clustersWithDetails := make([]map[string]any, 0) + for clusterName := range hostsByCluster { + clusterId, found := clusterNameToId[clusterName] + if !found { + // Try to find clusterId from clustersList by name + for _, cluster := range clustersList { + if name, ok := cluster["name"].(string); ok && name == clusterName { + if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil { + var id int + switch v := idRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + id = int(val) + } + case float64: + id = int(v) + case int: + id = v + case int64: + id = int(v) + } + clusterId = id + break + } + } + } + } + if clusterId > 0 { + clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId) + var clusterDetail map[string]any + if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil { + // Format drsStatus + if drsStatus, ok := clusterDetail["drsStatus"].(string); ok { + if drsStatus == "enabled" { + clusterDetail["drsStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus + } + } + // Format haStatus + if haStatus, ok := clusterDetail["haStatus"].(string); ok { + if haStatus == "enabled" { + clusterDetail["haStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["haStatusFormatted"] = "🔴 " + haStatus + } + } + // Remove unwanted fields + delete(clusterDetail, "vmwareClusterId") + delete(clusterDetail, "autoscale") + clustersWithDetails = append(clustersWithDetails, clusterDetail) + } + } + } + object["clusters"] = clustersWithDetails + + // Fetch local filers (datacenter level) + localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(args[1])) + localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching local filers for %s: %s", args[1], err) + return + } + + // Fetch global filers (service level) + globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0])) + globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "") + if err != nil { + // If error, just continue with empty list + globalFilers = []map[string]any{} + } + + // Combine both lists + allFilers := make([]map[string]any, 0, len(localFilers)+len(globalFilers)) + allFilers = append(allFilers, localFilers...) + allFilers = append(allFilers, globalFilers...) + + // Enrich filers with formatted data + for i := range allFilers { + // Set visibility based on source + if i < len(localFilers) { + allFilers[i]["visibility"] = "Local" + } else { + allFilers[i]["visibility"] = "Global" + } + // Format size + sizeStr := "" + if size, ok := allFilers[i]["size"].(map[string]any); ok { + var sizeValue float64 + if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { + switch v := sizeValueRaw.(type) { + case json.Number: + var err error + sizeValue, err = v.Float64() + if err != nil { + sizeValue = 0 + } + case float64: + sizeValue = v + case int: + sizeValue = float64(v) + case int64: + sizeValue = float64(v) + case int32: + sizeValue = float64(v) + } + } + if sizeValue > 0 { + sizeStr = fmt.Sprintf("%.0f", sizeValue) + if sizeUnit, ok := size["unit"].(string); ok { + sizeStr += " " + sizeUnit + } + } + } + allFilers[i]["sizeFormatted"] = sizeStr + + // Format spaceFree + spaceFreeStr := "" + if spaceFreeRaw, ok := allFilers[i]["spaceFree"]; ok && spaceFreeRaw != nil { + var spaceFreeValue float64 + switch v := spaceFreeRaw.(type) { + case json.Number: + var err error + spaceFreeValue, err = v.Float64() + if err != nil { + spaceFreeValue = 0 + } + case float64: + spaceFreeValue = v + case int: + spaceFreeValue = float64(v) + case int64: + spaceFreeValue = float64(v) + case int32: + spaceFreeValue = float64(v) + } + if spaceFreeValue > 0 { + spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue) + } + } + allFilers[i]["spaceFreeFormatted"] = spaceFreeStr + + // Extract cluster name from master (first part of domain) + clusterName := "" + if master, ok := allFilers[i]["master"].(string); ok && master != "" { + // Extract first part before first dot + parts := strings.Split(master, ".") + if len(parts) > 0 { + clusterName = parts[0] + } + } + allFilers[i]["clusterName"] = clusterName + + // Add VM count + vmCount := 0 + if vmTotalRaw, ok := allFilers[i]["vmTotal"]; ok && vmTotalRaw != nil { + switch v := vmTotalRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + vmCount = int(val) + } + case float64: + vmCount = int(v) + case int: + vmCount = v + case int64: + vmCount = int(v) + case int32: + vmCount = int(v) + } + } + allFilers[i]["vmCount"] = vmCount + + // Add connection state indicator + connectionStateIndicator := "🔴" + if connectionState, ok := allFilers[i]["connectionState"].(string); ok && connectionState == "online" { + connectionStateIndicator = "🟢" + } + allFilers[i]["connectionStateIndicator"] = connectionStateIndicator + } + object["filers"] = allFilers + + // Calculate totals + totalCores := 0 + totalRAM := 0.0 + totalVMs := 0 + totalDiskSpace := 0.0 + + // Sum from hosts + for _, host := range hosts { + // Sum cores + if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { + var cpuNumValue float64 + switch v := cpuNumRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + cpuNumValue = val + } + case float64: + cpuNumValue = v + case int: + cpuNumValue = float64(v) + case int64: + cpuNumValue = float64(v) + case int32: + cpuNumValue = float64(v) + } + totalCores += int(cpuNumValue) + } + + // Sum RAM + if ram, ok := host["ram"].(map[string]any); ok { + if ramValue, ok := ram["value"].(float64); ok { + totalRAM += ramValue + } else if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { + switch v := ramValueRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + totalRAM += val + } + case float64: + totalRAM += v + case int: + totalRAM += float64(v) + case int64: + totalRAM += float64(v) + } + } + } + + // Sum VMs + if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { + switch v := vmTotalRaw.(type) { + case json.Number: + val, err := v.Int64() + if err == nil { + totalVMs += int(val) + } + case float64: + totalVMs += int(v) + case int: + totalVMs += v + case int64: + totalVMs += int(v) + case int32: + totalVMs += int(v) + } + } + } + + // Sum disk space from filers + for _, filer := range allFilers { + if size, ok := filer["size"].(map[string]any); ok { + var sizeValue float64 + if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { + switch v := sizeValueRaw.(type) { + case json.Number: + val, err := v.Float64() + if err == nil { + sizeValue = val + } + case float64: + sizeValue = v + case int: + sizeValue = float64(v) + case int64: + sizeValue = float64(v) + case int32: + sizeValue = float64(v) + } + // Convert to GB if needed + if sizeUnit, ok := size["unit"].(string); ok { + if strings.ToUpper(sizeUnit) == "TB" { + sizeValue *= 1000 // Convert TB to GB + } else if strings.ToUpper(sizeUnit) == "MB" { + sizeValue /= 1000 // Convert MB to GB + } + } + totalDiskSpace += sizeValue + } + } + } + + // Format totals + object["totalCores"] = totalCores + object["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM) + object["totalVMs"] = totalVMs + object["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace) + + // Fetch IAM information from parent dedicatedcloud + dedicatedcloudEndpoint := fmt.Sprintf("/dedicatedCloud/%s", url.PathEscape(args[0])) + var dedicatedcloud map[string]any + if err := httpLib.Client.Get(dedicatedcloudEndpoint, &dedicatedcloud); err == nil { + if iam, ok := dedicatedcloud["iam"].(map[string]any); ok { + object["iam"] = iam + } + } + + display.OutputObject(object, args[1], datacenterTemplate, &flags.OutputFormatConfig) } diff --git a/internal/services/dedicatedcloud/templates/datacenter.tmpl b/internal/services/dedicatedcloud/templates/datacenter.tmpl new file mode 100644 index 00000000..786b489a --- /dev/null +++ b/internal/services/dedicatedcloud/templates/datacenter.tmpl @@ -0,0 +1,71 @@ +🏢 Datacenter {{.ServiceName}} +======= + +## General information + +**ID**: {{index .Result "datacenterId"}} +**Name**: {{index .Result "name"}} +**Version**: {{index .Result "version"}} +**Commercial name**: {{index .Result "commercialName"}} +{{if index .Result "commercialRangeName"}}**Commercial range**: {{index .Result "commercialRangeName"}} +{{end}}**Removable**: {{index .Result "isRemovable"}} +{{if index .Result "description"}}**Description**: {{index .Result "description"}} +{{end}}{{if index .Result "horizonViewName"}}**Horizon View**: {{index .Result "horizonViewName"}} +{{end}} +{{if index .Result "totalCores"}}**Total CPU (Cores)**: {{index .Result "totalCores"}} +{{end}}{{if index .Result "totalRAM"}}**Total RAM**: {{index .Result "totalRAM"}} +{{end}}{{if index .Result "totalVMs"}}**Total VMs**: {{index .Result "totalVMs"}} +{{end}}{{if index .Result "totalDiskSpace"}}**Total Disk Space**: {{index .Result "totalDiskSpace"}} +{{end}} + +## Clusters + +{{if index .Result "clusters"}} +| ID | Name | DRS Status | DRS Mode | HA Status | EVC Mode | +| --- | --- | --- | --- | --- | --- | +{{ range index .Result "clusters" }}| {{index . "clusterId"}} | {{index . "name"}} | {{index . "drsStatusFormatted"}} | {{index . "drsMode"}} | {{index . "haStatusFormatted"}} | {{index . "evcMode"}} | +{{end}} +{{else}} +No clusters found +{{end}} + +## Hosts + +{{if index .Result "hostsByCluster"}} +{{ range $clusterName, $hosts := (index .Result "hostsByCluster") }} +### Cluster: {{$clusterName}} + +| ID | Name | Connection | State | Cores | RAM | Profile | VMs | Billing | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +{{ range $hosts }}| {{index . "hostId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "maintenanceStatus"}} | {{index . "coreNumber"}} | {{if index . "ram"}}{{index . "ram" "value"}}{{if index . "ram" "unit"}} {{index . "ram" "unit"}}{{end}}{{end}} | {{index . "profile"}} | {{index . "vmCount"}} | {{index . "billingType"}} | +{{end}} +{{end}} +{{else}} +No hosts found +{{end}} + +## Filers + +{{if index .Result "filers"}} +| ID | Name | Connection | State | Size | Space Free | Cluster | VMs | Billing | Active Node | Visibility | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +{{ range index .Result "filers" }}| {{index . "filerId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "state"}} | {{index . "sizeFormatted"}} | {{index . "spaceFreeFormatted"}} | {{index . "clusterName"}} | {{index . "vmCount"}} | {{index . "billingType"}} | {{index . "activeNode"}} | {{index . "visibility"}} | +{{end}} +{{else}} +No filers found +{{end}} + +## IAM information + +{{if index .Result "iam"}} +**URN**: {{index .Result "iam" "urn"}} +{{if index .Result "iam" "tags"}} +**Tags**: +| Key | Value | +| --- | --- | +{{ range $key, $value := (index .Result "iam" "tags") }}| {{$key}} | {{$value}} | +{{end}} +{{end}} +{{end}} + +💡 Use option --json or --yaml to get the raw output with all information diff --git a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl index b993aebd..f846426c 100644 --- a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl +++ b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl @@ -6,7 +6,7 @@ _{{index .Result "iam" "displayName"}}_ ## General information -**Location**: {{index .Result "location"}} +**Location**: {{if index .Result "regionLocation"}}{{index .Result "regionLocation"}}{{else}}{{index .Result "location"}}{{end}} **State**: {{index .Result "state"}} **Generation**: {{index .Result "generation"}} **Version**: {{index .Result "version" "major"}}.{{index .Result "version" "minor"}}.{{index .Result "version" "build"}} @@ -26,6 +26,17 @@ _{{index .Result "iam" "displayName"}}_ | SSLv3 | {{index .Result "sslV3"}} | | User access policy | {{index .Result "userAccessPolicy"}} | +## Datacenters + +{{if index .Result "datacenters"}} +| ID | Name | Version | Commercial name | +| --- | --- | --- | --- | +{{ range index .Result "datacenters" }}| {{index . "datacenterId"}} | {{index . "name"}} | {{index . "version"}} | {{index . "commercialName"}} | +{{end}} +{{else}} +No datacenters found +{{end}} + ## IAM information **URN**: {{index .Result "iam" "urn"}} From 726006e792c6acae10bd68b59c5cdbfa1fe74727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 15:26:02 +0100 Subject: [PATCH 02/20] test(dedicated-cloud): Add comprehensive tests for dedicated-cloud commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TestDedicatedCloudListCmd to test list command with regionLocation and version formatting - Add TestDedicatedCloudGetCmd to test get command with datacenters table - Add TestDedicatedCloudDatacenterListCmd to test datacenter list with totals calculation - Add TestDedicatedCloudDatacenterGetCmd to test datacenter get with clusters, hosts, and filers All tests use httpmock to mock API responses and verify the correct display of enriched data. Signed-off-by: François Loiseau --- internal/cmd/dedicatedcloud_test.go | 300 ++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 internal/cmd/dedicatedcloud_test.go diff --git a/internal/cmd/dedicatedcloud_test.go b/internal/cmd/dedicatedcloud_test.go new file mode 100644 index 00000000..7ac28d48 --- /dev/null +++ b/internal/cmd/dedicatedcloud_test.go @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +func (ms *MockSuite) TestDedicatedCloudListCmd(assert, require *td.T) { + // Mock the list of dedicated clouds + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud", + httpmock.NewStringResponder(200, `["pcc-12345","pcc-67890"]`).Once()) + + // Mock expanded dedicated cloud details + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345", + httpmock.NewStringResponder(200, `{ + "serviceName": "pcc-12345", + "location": "pcc-12345", + "state": "delivered", + "description": "Test PCC", + "version": { + "major": "8", + "minor": "0", + "build": "U3e.24674346" + } + }`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-67890", + httpmock.NewStringResponder(200, `{ + "serviceName": "pcc-67890", + "location": "pcc-67890", + "state": "delivered", + "description": "Test PCC 2", + "version": { + "major": "8", + "minor": "0", + "build": "U3g.24853646" + } + }`).Once()) + + // Mock location list + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location", + httpmock.NewStringResponder(200, `["pcc-12345","pcc-67890"]`).Once()) + + // Mock location details + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-12345", + httpmock.NewStringResponder(200, `{ + "regionLocation": "Europe (France - Gravelines)" + }`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-67890", + httpmock.NewStringResponder(200, `{ + "regionLocation": "Europe (United Kingdom - Erith)" + }`).Once()) + + out, err := cmd.Execute("dedicated-cloud", "list") + + require.CmpNoError(err) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-12345")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-67890")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Europe (France - Gravelines)")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("8.0.U3e.24674346")) +} + +func (ms *MockSuite) TestDedicatedCloudGetCmd(assert, require *td.T) { + // Mock dedicated cloud details + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345", + httpmock.NewStringResponder(200, `{ + "serviceName": "pcc-12345", + "location": "pcc-12345", + "state": "delivered", + "generation": "8", + "version": { + "major": "8", + "minor": "0", + "build": "U3e.24674346" + }, + "commercialRange": "nsx-t", + "billingType": "hourly", + "webInterfaceUrl": "https://pcc-12345.ovh.com", + "vScopeUrl": "https://vscope.ovh.com/pcc-12345", + "advancedSecurity": true, + "bandwidth": "1Gbps", + "spla": false, + "sslV3": false, + "userAccessPolicy": "readOnly", + "iam": { + "displayName": "PCC-12345", + "urn": "urn:v1:eu:resource:pccVMware:pcc-12345" + } + }`).Once()) + + // Mock datacenters list + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter", + httpmock.NewStringResponder(200, `[1, 2]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1", + httpmock.NewStringResponder(200, `{ + "datacenterId": 1, + "name": "datacenter-1", + "version": "8.0", + "commercialName": "Enterprise" + }`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/2", + httpmock.NewStringResponder(200, `{ + "datacenterId": 2, + "name": "datacenter-2", + "version": "8.0", + "commercialName": "Standard" + }`).Once()) + + // Mock location list and details for regionLocation + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location", + httpmock.NewStringResponder(200, `["pcc-12345"]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-12345", + httpmock.NewStringResponder(200, `{ + "regionLocation": "Europe (France - Gravelines)" + }`).Once()) + + out, err := cmd.Execute("dedicated-cloud", "get", "pcc-12345") + + require.CmpNoError(err) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-12345")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Europe (France - Gravelines)")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("8.0.U3e.24674346")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-2")) +} + +func (ms *MockSuite) TestDedicatedCloudDatacenterListCmd(assert, require *td.T) { + // Mock datacenters list + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter", + httpmock.NewStringResponder(200, `[1]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1", + httpmock.NewStringResponder(200, `{ + "datacenterId": 1, + "name": "datacenter-1", + "version": "8.0", + "commercialName": "Enterprise" + }`).Once()) + + // Mock hosts for totals calculation + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host", + httpmock.NewStringResponder(200, `[1481, 1511]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1481", + httpmock.NewStringResponder(200, `{ + "hostId": 1481, + "cpuNum": 32, + "ram": { + "value": 768, + "unit": "GB" + }, + "vmTotal": 36 + }`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1511", + httpmock.NewStringResponder(200, `{ + "hostId": 1511, + "cpuNum": 32, + "ram": { + "value": 768, + "unit": "GB" + }, + "vmTotal": 16 + }`).Once()) + + // Mock filers for totals calculation + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer", + httpmock.NewStringResponder(200, `[1557]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer/1557", + httpmock.NewStringResponder(200, `{ + "filerId": 1557, + "size": { + "value": 3000, + "unit": "GB" + } + }`).Once()) + + // Mock global filers + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/filer", + httpmock.NewStringResponder(200, `[]`).Once()) + + out, err := cmd.Execute("dedicated-cloud", "datacenter", "list", "pcc-12345") + + require.CmpNoError(err) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("64")) // totalCores: 32 + 32 + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("1536 GB")) // totalRAM: 768 + 768 + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("52")) // totalVMs: 36 + 16 +} + +func (ms *MockSuite) TestDedicatedCloudDatacenterGetCmd(assert, require *td.T) { + // Mock datacenter details + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1", + httpmock.NewStringResponder(200, `{ + "datacenterId": 1, + "name": "datacenter-1", + "version": "8.0", + "commercialName": "Enterprise", + "commercialRange": "nsx-t", + "removable": false + }`).Once()) + + // Mock clusters + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/cluster", + httpmock.NewStringResponder(200, `[25035]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/cluster/25035", + httpmock.NewStringResponder(200, `{ + "clusterId": 25035, + "name": "Cluster1", + "drsStatus": "enabled", + "drsMode": "fullyAutomated", + "haStatus": "enabled", + "evcMode": "disabled" + }`).Once()) + + // Mock hosts + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host", + httpmock.NewStringResponder(200, `[1481]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1481", + httpmock.NewStringResponder(200, `{ + "hostId": 1481, + "name": "172.17.136.56", + "clusterName": "Cluster1", + "clusterId": 25035, + "connectionState": "connected", + "inMaintenance": false, + "cpuNum": 32, + "cpu": { + "value": 93, + "unit": "GHz" + }, + "ram": { + "value": 768, + "unit": "GB" + }, + "profile": "PRE 768 NSX", + "vmTotal": 36, + "billingType": "hourly" + }`).Once()) + + // Mock local filers + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer", + httpmock.NewStringResponder(200, `[1557]`).Once()) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer/1557", + httpmock.NewStringResponder(200, `{ + "filerId": 1557, + "name": "ssd-001557", + "connectionState": "online", + "state": "delivered", + "size": { + "value": 3000, + "unit": "GB" + }, + "spaceFree": { + "value": 2972, + "unit": "GB" + }, + "master": "cluster5068.example.com", + "vmTotal": 0, + "billingType": "hourly", + "activeNode": "master" + }`).Once()) + + // Mock global filers + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/filer", + httpmock.NewStringResponder(200, `[]`).Once()) + + // Mock dedicated cloud for IAM + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345", + httpmock.NewStringResponder(200, `{ + "iam": { + "displayName": "PCC-12345", + "urn": "urn:v1:eu:resource:pccVMware:pcc-12345" + } + }`).Once()) + + out, err := cmd.Execute("dedicated-cloud", "datacenter", "get", "pcc-12345", "1") + + require.CmpNoError(err) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Cluster1")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("172.17")) // IP address (may be split across lines) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("93GHz")) // CPU frequency + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("1557")) // Filer ID + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Local")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("urn:v1:eu:resource:pccVMware:pcc-12345")) +} From 3383a9192d0711e9d19cfdf730d8ea02ef3f0376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 15:33:44 +0100 Subject: [PATCH 03/20] fix(dedicated-cloud): Move datacenters condition to wrap section title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move {{if index .Result "datacenters"}} condition above the '## Datacenters' title so that the entire section (including title) is conditionally displayed. If the datacenters list is empty, the section won't be shown at all. Signed-off-by: François Loiseau --- .../services/dedicatedcloud/templates/dedicatedcloud.tmpl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl index f846426c..04584689 100644 --- a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl +++ b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl @@ -26,15 +26,13 @@ _{{index .Result "iam" "displayName"}}_ | SSLv3 | {{index .Result "sslV3"}} | | User access policy | {{index .Result "userAccessPolicy"}} | +{{if index .Result "datacenters"}} ## Datacenters -{{if index .Result "datacenters"}} | ID | Name | Version | Commercial name | | --- | --- | --- | --- | {{ range index .Result "datacenters" }}| {{index . "datacenterId"}} | {{index . "name"}} | {{index . "version"}} | {{index . "commercialName"}} | {{end}} -{{else}} -No datacenters found {{end}} ## IAM information From 6aa35fe7091cde17de408dc0016f4023b7929ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 16:12:50 +0100 Subject: [PATCH 04/20] refactor(dedicated-cloud): Simplify datacenterId type conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the type switch for datacenterId conversion with fmt.Sprint(). Since the API schemas guarantee datacenterId is an int, we can trust the API and use fmt.Sprint() which handles all numeric types automatically. This simplifies the code and makes it more maintainable. Signed-off-by: François Loiseau --- internal/services/dedicatedcloud/dedicatedcloud.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index e59bc178..6f620598 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -161,18 +161,7 @@ func ListDatacenter(_ *cobra.Command, args []string) { for i := range datacenters { datacenterId := "" if idRaw, ok := datacenters[i]["datacenterId"]; ok && idRaw != nil { - switch v := idRaw.(type) { - case json.Number: - datacenterId = v.String() - case float64: - datacenterId = fmt.Sprintf("%.0f", v) - case int: - datacenterId = fmt.Sprintf("%d", v) - case int64: - datacenterId = fmt.Sprintf("%d", v) - case string: - datacenterId = v - } + datacenterId = fmt.Sprint(idRaw) } if datacenterId == "" { From 21137a4e33d2ef117055d728310307dd98162f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 16:55:30 +0100 Subject: [PATCH 05/20] fix(dedicated-cloud): Add json.Number support to toFloat64 and toInt helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper functions toFloat64 and toInt were not handling json.Number type, which caused all totals (CPU cores, RAM, VMs, disk space) to be displayed as 0 in the datacenter list and get commands. This commit adds support for json.Number type conversion in both helper functions, ensuring that numeric values are correctly extracted and summed regardless of their JSON deserialization type. Signed-off-by: François Loiseau --- .../services/dedicatedcloud/dedicatedcloud.go | 321 ++++-------------- 1 file changed, 62 insertions(+), 259 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 6f620598..37fac510 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -28,6 +28,38 @@ var ( datacenterTemplate string ) +// toFloat64 converts any numeric type to float64 +func toFloat64(v any) float64 { + if f, ok := v.(float64); ok { + return f + } + if i, ok := v.(int); ok { + return float64(i) + } + if n, ok := v.(json.Number); ok { + if f, err := n.Float64(); err == nil { + return f + } + } + return 0 +} + +// toInt converts any numeric type to int +func toInt(v any) int { + if i, ok := v.(int); ok { + return i + } + if f, ok := v.(float64); ok { + return int(f) + } + if n, ok := v.(json.Number); ok { + if i, err := n.Int64(); err == nil { + return int(i) + } + } + return 0 +} + // getLocationMap fetches the location mapping from the API func getLocationMap() (map[string]string, error) { // Fetch location list (array of location IDs) @@ -200,86 +232,27 @@ func ListDatacenter(_ *cobra.Command, args []string) { for _, host := range hosts { // Sum cores if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { - var cpuNumValue float64 - switch v := cpuNumRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - cpuNumValue = val - } - case float64: - cpuNumValue = v - case int: - cpuNumValue = float64(v) - case int64: - cpuNumValue = float64(v) - case int32: - cpuNumValue = float64(v) - } - totalCores += int(cpuNumValue) + totalCores += int(toFloat64(cpuNumRaw)) } // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValue, ok := ram["value"].(float64); ok { - totalRAM += ramValue - } else if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { - switch v := ramValueRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - totalRAM += val - } - case float64: - totalRAM += v - case int: - totalRAM += float64(v) - case int64: - totalRAM += float64(v) - } + if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { + totalRAM += toFloat64(ramValueRaw) } } // Sum VMs if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - switch v := vmTotalRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - totalVMs += int(val) - } - case float64: - totalVMs += int(v) - case int: - totalVMs += v - case int64: - totalVMs += int(v) - case int32: - totalVMs += int(v) - } + totalVMs += toInt(vmTotalRaw) } } // Sum disk space from filers for _, filer := range allFilers { if size, ok := filer["size"].(map[string]any); ok { - var sizeValue float64 if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { - switch v := sizeValueRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - sizeValue = val - } - case float64: - sizeValue = v - case int: - sizeValue = float64(v) - case int64: - sizeValue = float64(v) - case int32: - sizeValue = float64(v) - } + sizeValue := toFloat64(sizeValueRaw) // Convert to GB if needed if sizeUnit, ok := size["unit"].(string); ok { if strings.ToUpper(sizeUnit) == "TB" { @@ -335,40 +308,16 @@ func GetDatacenter(_ *cobra.Command, args []string) { // Format Core Number with GHz in parentheses coreNumber := "" if cpuNumRaw, ok := hosts[i]["cpuNum"]; ok && cpuNumRaw != nil { - var cpuNumValue float64 - switch v := cpuNumRaw.(type) { - case json.Number: - var err error - cpuNumValue, err = v.Float64() - if err != nil { - cpuNumValue = 0 - } - case float64: - cpuNumValue = v - case int: - cpuNumValue = float64(v) - case int64: - cpuNumValue = float64(v) - case int32: - cpuNumValue = float64(v) - } + cpuNumValue := toFloat64(cpuNumRaw) if cpuNumValue > 0 { coreNumber = fmt.Sprintf("%.0f", cpuNumValue) if cpu, ok := hosts[i]["cpu"].(map[string]any); ok { - var cpuValue float64 - switch cv := cpu["value"].(type) { - case json.Number: - var err error - cpuValue, err = cv.Float64() - if err != nil { - cpuValue = 0 + if cpuValueRaw, ok := cpu["value"]; ok && cpuValueRaw != nil { + cpuValue := toFloat64(cpuValueRaw) + if cpuValue > 0 { + // Use format: cores (freqGHz) + coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue) } - case float64: - cpuValue = cv - } - if cpuValue > 0 { - // Use format: cores (freqGHz) - coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue) } } } @@ -376,25 +325,11 @@ func GetDatacenter(_ *cobra.Command, args []string) { hosts[i]["coreNumber"] = coreNumber // Add VM count - vmCount := 0 if vmTotalRaw, ok := hosts[i]["vmTotal"]; ok && vmTotalRaw != nil { - switch v := vmTotalRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - vmCount = int(val) - } - case float64: - vmCount = int(v) - case int: - vmCount = v - case int64: - vmCount = int(v) - case int32: - vmCount = int(v) - } + hosts[i]["vmCount"] = toInt(vmTotalRaw) + } else { + hosts[i]["vmCount"] = 0 } - hosts[i]["vmCount"] = vmCount // Add maintenance status emoji if inMaintenance, ok := hosts[i]["inMaintenance"].(bool); ok && inMaintenance { @@ -433,20 +368,7 @@ func GetDatacenter(_ *cobra.Command, args []string) { for _, host := range hosts { if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" { if clusterIdRaw, ok := host["clusterId"]; ok && clusterIdRaw != nil { - var clusterId int - switch v := clusterIdRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - clusterId = int(val) - } - case float64: - clusterId = int(v) - case int: - clusterId = v - case int64: - clusterId = int(v) - } + clusterId := toInt(clusterIdRaw) if clusterId > 0 { clusterNameToId[clusterName] = clusterId } @@ -463,21 +385,7 @@ func GetDatacenter(_ *cobra.Command, args []string) { for _, cluster := range clustersList { if name, ok := cluster["name"].(string); ok && name == clusterName { if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil { - var id int - switch v := idRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - id = int(val) - } - case float64: - id = int(v) - case int: - id = v - case int64: - id = int(v) - } - clusterId = id + clusterId = toInt(idRaw) break } } @@ -544,29 +452,13 @@ func GetDatacenter(_ *cobra.Command, args []string) { // Format size sizeStr := "" if size, ok := allFilers[i]["size"].(map[string]any); ok { - var sizeValue float64 if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { - switch v := sizeValueRaw.(type) { - case json.Number: - var err error - sizeValue, err = v.Float64() - if err != nil { - sizeValue = 0 + sizeValue := toFloat64(sizeValueRaw) + if sizeValue > 0 { + sizeStr = fmt.Sprintf("%.0f", sizeValue) + if sizeUnit, ok := size["unit"].(string); ok { + sizeStr += " " + sizeUnit } - case float64: - sizeValue = v - case int: - sizeValue = float64(v) - case int64: - sizeValue = float64(v) - case int32: - sizeValue = float64(v) - } - } - if sizeValue > 0 { - sizeStr = fmt.Sprintf("%.0f", sizeValue) - if sizeUnit, ok := size["unit"].(string); ok { - sizeStr += " " + sizeUnit } } } @@ -575,23 +467,7 @@ func GetDatacenter(_ *cobra.Command, args []string) { // Format spaceFree spaceFreeStr := "" if spaceFreeRaw, ok := allFilers[i]["spaceFree"]; ok && spaceFreeRaw != nil { - var spaceFreeValue float64 - switch v := spaceFreeRaw.(type) { - case json.Number: - var err error - spaceFreeValue, err = v.Float64() - if err != nil { - spaceFreeValue = 0 - } - case float64: - spaceFreeValue = v - case int: - spaceFreeValue = float64(v) - case int64: - spaceFreeValue = float64(v) - case int32: - spaceFreeValue = float64(v) - } + spaceFreeValue := toFloat64(spaceFreeRaw) if spaceFreeValue > 0 { spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue) } @@ -610,25 +486,11 @@ func GetDatacenter(_ *cobra.Command, args []string) { allFilers[i]["clusterName"] = clusterName // Add VM count - vmCount := 0 if vmTotalRaw, ok := allFilers[i]["vmTotal"]; ok && vmTotalRaw != nil { - switch v := vmTotalRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - vmCount = int(val) - } - case float64: - vmCount = int(v) - case int: - vmCount = v - case int64: - vmCount = int(v) - case int32: - vmCount = int(v) - } + allFilers[i]["vmCount"] = toInt(vmTotalRaw) + } else { + allFilers[i]["vmCount"] = 0 } - allFilers[i]["vmCount"] = vmCount // Add connection state indicator connectionStateIndicator := "🔴" @@ -649,86 +511,27 @@ func GetDatacenter(_ *cobra.Command, args []string) { for _, host := range hosts { // Sum cores if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { - var cpuNumValue float64 - switch v := cpuNumRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - cpuNumValue = val - } - case float64: - cpuNumValue = v - case int: - cpuNumValue = float64(v) - case int64: - cpuNumValue = float64(v) - case int32: - cpuNumValue = float64(v) - } - totalCores += int(cpuNumValue) + totalCores += int(toFloat64(cpuNumRaw)) } // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValue, ok := ram["value"].(float64); ok { - totalRAM += ramValue - } else if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { - switch v := ramValueRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - totalRAM += val - } - case float64: - totalRAM += v - case int: - totalRAM += float64(v) - case int64: - totalRAM += float64(v) - } + if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { + totalRAM += toFloat64(ramValueRaw) } } // Sum VMs if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - switch v := vmTotalRaw.(type) { - case json.Number: - val, err := v.Int64() - if err == nil { - totalVMs += int(val) - } - case float64: - totalVMs += int(v) - case int: - totalVMs += v - case int64: - totalVMs += int(v) - case int32: - totalVMs += int(v) - } + totalVMs += toInt(vmTotalRaw) } } // Sum disk space from filers for _, filer := range allFilers { if size, ok := filer["size"].(map[string]any); ok { - var sizeValue float64 if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { - switch v := sizeValueRaw.(type) { - case json.Number: - val, err := v.Float64() - if err == nil { - sizeValue = val - } - case float64: - sizeValue = v - case int: - sizeValue = float64(v) - case int64: - sizeValue = float64(v) - case int32: - sizeValue = float64(v) - } + sizeValue := toFloat64(sizeValueRaw) // Convert to GB if needed if sizeUnit, ok := size["unit"].(string); ok { if strings.ToUpper(sizeUnit) == "TB" { From 49ee14bca84795e69073a065630db39a2e4f298b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 17:19:50 +0100 Subject: [PATCH 06/20] refactor(dedicated-cloud): Remove IAM from datacenter get and fix table wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove IAM information fetch from datacenter get command as it belongs to the dedicated cloud service, not the datacenter. Users can get IAM info by running dedicated-cloud get separately if needed. - Add glamour.WithWordWrap(0) to disable word wrapping for markdown tables, ensuring hosts and filers tables display on a single line - Restore full column names (Connection, State, Space Free, etc.) and remove name truncation to display all information without cutting Signed-off-by: François Loiseau --- internal/display/display.go | 1 + internal/services/dedicatedcloud/dedicatedcloud.go | 9 --------- .../dedicatedcloud/templates/datacenter.tmpl | 13 ------------- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/internal/display/display.go b/internal/display/display.go index 095ab52d..92c8cacb 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -275,6 +275,7 @@ func OutputObject(value map[string]any, serviceName, templateContent string, out r, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithPreservedNewLines(), + glamour.WithWordWrap(0), ) if err != nil { exitError("failed to init rendered: %s", err) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 37fac510..8e0ff163 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -551,14 +551,5 @@ func GetDatacenter(_ *cobra.Command, args []string) { object["totalVMs"] = totalVMs object["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace) - // Fetch IAM information from parent dedicatedcloud - dedicatedcloudEndpoint := fmt.Sprintf("/dedicatedCloud/%s", url.PathEscape(args[0])) - var dedicatedcloud map[string]any - if err := httpLib.Client.Get(dedicatedcloudEndpoint, &dedicatedcloud); err == nil { - if iam, ok := dedicatedcloud["iam"].(map[string]any); ok { - object["iam"] = iam - } - } - display.OutputObject(object, args[1], datacenterTemplate, &flags.OutputFormatConfig) } diff --git a/internal/services/dedicatedcloud/templates/datacenter.tmpl b/internal/services/dedicatedcloud/templates/datacenter.tmpl index 786b489a..d5ec216e 100644 --- a/internal/services/dedicatedcloud/templates/datacenter.tmpl +++ b/internal/services/dedicatedcloud/templates/datacenter.tmpl @@ -55,17 +55,4 @@ No hosts found No filers found {{end}} -## IAM information - -{{if index .Result "iam"}} -**URN**: {{index .Result "iam" "urn"}} -{{if index .Result "iam" "tags"}} -**Tags**: -| Key | Value | -| --- | --- | -{{ range $key, $value := (index .Result "iam" "tags") }}| {{$key}} | {{$value}} | -{{end}} -{{end}} -{{end}} - 💡 Use option --json or --yaml to get the raw output with all information From 3d42673cbaa2b5fa7bcdc6be34cddc9e920fc3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 17:27:54 +0100 Subject: [PATCH 07/20] refactor(dedicated-cloud): Simplify version formatting code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify version field extraction by using direct type assertion with blank identifier instead of checking ok and assigning separately. Signed-off-by: François Loiseau --- .../services/dedicatedcloud/dedicatedcloud.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 8e0ff163..e43ec4e4 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -104,18 +104,9 @@ func ListDedicatedCloud(_ *cobra.Command, _ []string) { for _, obj := range body { // Format version if versionObj, ok := obj["version"].(map[string]any); ok { - major := "" - minor := "" - build := "" - if v, ok := versionObj["major"].(string); ok { - major = v - } - if v, ok := versionObj["minor"].(string); ok { - minor = v - } - if v, ok := versionObj["build"].(string); ok { - build = v - } + major, _ := versionObj["major"].(string) + minor, _ := versionObj["minor"].(string) + build, _ := versionObj["build"].(string) versionStr := major if minor != "" { versionStr += "." + minor From ff5348955d32f29b1f42d341331bca0b5f9ea5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 17:30:06 +0100 Subject: [PATCH 08/20] refactor(dedicated-cloud): Further simplify version formatting code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use direct type assertion for versionStr from major field and use local variables in if statements for minor and build fields. Also add empty string checks to avoid adding dots when values are empty. Signed-off-by: François Loiseau --- internal/services/dedicatedcloud/dedicatedcloud.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index e43ec4e4..81ca33f9 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -104,14 +104,11 @@ func ListDedicatedCloud(_ *cobra.Command, _ []string) { for _, obj := range body { // Format version if versionObj, ok := obj["version"].(map[string]any); ok { - major, _ := versionObj["major"].(string) - minor, _ := versionObj["minor"].(string) - build, _ := versionObj["build"].(string) - versionStr := major - if minor != "" { + versionStr, _ := versionObj["major"].(string) + if minor, ok := versionObj["minor"].(string); ok && minor != "" { versionStr += "." + minor } - if build != "" { + if build, ok := versionObj["build"].(string); ok && build != "" { versionStr += "." + build } obj["version"] = versionStr From e547f28bb4d16eb9f48ffbac27ea81ff5700d73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Fri, 14 Nov 2025 18:10:30 +0100 Subject: [PATCH 09/20] refactor(dedicated-cloud): Replace flags with subcommands for datacenter details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace --hosts, --filers, --clusters flags with dedicated subcommands: - dedicated-cloud datacenter get: shows basic info + totals only - dedicated-cloud datacenter hosts: shows hosts information only - dedicated-cloud datacenter filers: shows filers information only - dedicated-cloud datacenter clusters: shows clusters information only This reduces the number of API calls per command and makes commands less error-prone, as suggested in code review. Users can now fetch only the information they need instead of everything at once. Totals (CPU cores, RAM, VMs, disk space) are only displayed in the main 'get' command, not in the subcommands. Signed-off-by: François Loiseau --- internal/cmd/dedicatedcloud.go | 24 +- .../services/dedicatedcloud/dedicatedcloud.go | 481 ++++++++++-------- .../dedicatedcloud/templates/datacenter.tmpl | 12 +- 3 files changed, 303 insertions(+), 214 deletions(-) diff --git a/internal/cmd/dedicatedcloud.go b/internal/cmd/dedicatedcloud.go index fac81856..854f1b1d 100644 --- a/internal/cmd/dedicatedcloud.go +++ b/internal/cmd/dedicatedcloud.go @@ -47,11 +47,33 @@ func init() { Run: dedicatedcloud.ListDatacenter, })) - dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{ + datacenterGetCmd := &cobra.Command{ Use: "get ", Short: "Get information about a specific datacenter of a DedicatedCloud", Args: cobra.ExactArgs(2), Run: dedicatedcloud.GetDatacenter, + } + dedicatedcloudDatacenterCmd.AddCommand(datacenterGetCmd) + + dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{ + Use: "hosts ", + Short: "Get hosts information for a specific datacenter", + Args: cobra.ExactArgs(2), + Run: dedicatedcloud.GetDatacenterHosts, + }) + + dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{ + Use: "filers ", + Short: "Get filers information for a specific datacenter", + Args: cobra.ExactArgs(2), + Run: dedicatedcloud.GetDatacenterFilers, + }) + + dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{ + Use: "clusters ", + Short: "Get clusters information for a specific datacenter", + Args: cobra.ExactArgs(2), + Run: dedicatedcloud.GetDatacenterClusters, }) rootCmd.AddCommand(dedicatedcloudCmd) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 81ca33f9..52e4e349 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -273,6 +273,22 @@ func ListDatacenter(_ *cobra.Command, args []string) { } func GetDatacenter(_ *cobra.Command, args []string) { + getDatacenterWithOptions(args, false, false, false, true) +} + +func GetDatacenterHosts(_ *cobra.Command, args []string) { + getDatacenterWithOptions(args, true, false, false, false) +} + +func GetDatacenterFilers(_ *cobra.Command, args []string) { + getDatacenterWithOptions(args, false, true, false, false) +} + +func GetDatacenterClusters(_ *cobra.Command, args []string) { + getDatacenterWithOptions(args, false, false, true, false) +} + +func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includeClusters, includeTotals bool) { path := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s", url.PathEscape(args[0]), url.PathEscape(args[1])) // Fetch datacenter @@ -282,262 +298,319 @@ func GetDatacenter(_ *cobra.Command, args []string) { return } - // Fetch hosts list - hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(args[1])) - hosts, err := httpLib.FetchExpandedArray(hostsEndpoint, "") - if err != nil { - display.OutputError(&flags.OutputFormatConfig, "error fetching hosts for %s: %s", args[1], err) - return + // Fetch hosts list if requested or if we need totals + var hosts []map[string]any + if includeHosts || includeTotals { + hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(args[1])) + var err error + hosts, err = httpLib.FetchExpandedArray(hostsEndpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching hosts for %s: %s", args[1], err) + return + } } // Enrich hosts with formatted data and group by cluster hostsByCluster := make(map[string][]map[string]any) - for i := range hosts { - // Format Core Number with GHz in parentheses - coreNumber := "" - if cpuNumRaw, ok := hosts[i]["cpuNum"]; ok && cpuNumRaw != nil { - cpuNumValue := toFloat64(cpuNumRaw) - if cpuNumValue > 0 { - coreNumber = fmt.Sprintf("%.0f", cpuNumValue) - if cpu, ok := hosts[i]["cpu"].(map[string]any); ok { - if cpuValueRaw, ok := cpu["value"]; ok && cpuValueRaw != nil { - cpuValue := toFloat64(cpuValueRaw) - if cpuValue > 0 { - // Use format: cores (freqGHz) - coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue) + if includeHosts { + for i := range hosts { + // Format Core Number with GHz in parentheses + coreNumber := "" + if cpuNumRaw, ok := hosts[i]["cpuNum"]; ok && cpuNumRaw != nil { + cpuNumValue := toFloat64(cpuNumRaw) + if cpuNumValue > 0 { + coreNumber = fmt.Sprintf("%.0f", cpuNumValue) + if cpu, ok := hosts[i]["cpu"].(map[string]any); ok { + if cpuValueRaw, ok := cpu["value"]; ok && cpuValueRaw != nil { + cpuValue := toFloat64(cpuValueRaw) + if cpuValue > 0 { + // Use format: cores (freqGHz) + coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue) + } } } } } - } - hosts[i]["coreNumber"] = coreNumber + hosts[i]["coreNumber"] = coreNumber - // Add VM count - if vmTotalRaw, ok := hosts[i]["vmTotal"]; ok && vmTotalRaw != nil { - hosts[i]["vmCount"] = toInt(vmTotalRaw) - } else { - hosts[i]["vmCount"] = 0 - } + // Add VM count + if vmTotalRaw, ok := hosts[i]["vmTotal"]; ok && vmTotalRaw != nil { + hosts[i]["vmCount"] = toInt(vmTotalRaw) + } else { + hosts[i]["vmCount"] = 0 + } - // Add maintenance status emoji - if inMaintenance, ok := hosts[i]["inMaintenance"].(bool); ok && inMaintenance { - hosts[i]["maintenanceStatus"] = "🔧" - } else { - hosts[i]["maintenanceStatus"] = "✅" - } + // Add maintenance status emoji + if inMaintenance, ok := hosts[i]["inMaintenance"].(bool); ok && inMaintenance { + hosts[i]["maintenanceStatus"] = "🔧" + } else { + hosts[i]["maintenanceStatus"] = "✅" + } - // Add connection state indicator - connectionStateIndicator := "🔴" - if connectionState, ok := hosts[i]["connectionState"].(string); ok && connectionState == "connected" { - connectionStateIndicator = "🟢" - } - hosts[i]["connectionStateIndicator"] = connectionStateIndicator + // Add connection state indicator + connectionStateIndicator := "🔴" + if connectionState, ok := hosts[i]["connectionState"].(string); ok && connectionState == "connected" { + connectionStateIndicator = "🟢" + } + hosts[i]["connectionStateIndicator"] = connectionStateIndicator - // Group by cluster - clusterName := "Unknown" - if cn, ok := hosts[i]["clusterName"].(string); ok && cn != "" { - clusterName = cn + // Group by cluster + clusterName := "Unknown" + if cn, ok := hosts[i]["clusterName"].(string); ok && cn != "" { + clusterName = cn + } + hostsByCluster[clusterName] = append(hostsByCluster[clusterName], hosts[i]) + } + if len(hostsByCluster) > 0 { + object["hostsByCluster"] = hostsByCluster + object["hosts"] = hosts // Keep original for backward compatibility } - hostsByCluster[clusterName] = append(hostsByCluster[clusterName], hosts[i]) } - object["hostsByCluster"] = hostsByCluster - object["hosts"] = hosts // Keep original for backward compatibility // Fetch clusters information - clustersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster", url.PathEscape(args[0]), url.PathEscape(args[1])) - clustersList, err := httpLib.FetchExpandedArray(clustersEndpoint, "") - if err != nil { - // If error, just continue with empty list - clustersList = []map[string]any{} - } - - // Build a map of clusterName to clusterId from hosts - clusterNameToId := make(map[string]int) - for _, host := range hosts { - if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" { - if clusterIdRaw, ok := host["clusterId"]; ok && clusterIdRaw != nil { - clusterId := toInt(clusterIdRaw) - if clusterId > 0 { - clusterNameToId[clusterName] = clusterId + if includeClusters { + clustersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster", url.PathEscape(args[0]), url.PathEscape(args[1])) + clustersList, err := httpLib.FetchExpandedArray(clustersEndpoint, "") + if err != nil { + // If error, just continue with empty list + clustersList = []map[string]any{} + } + + // Build a map of clusterName to clusterId from hosts + clusterNameToId := make(map[string]int) + if includeHosts { + for _, host := range hosts { + if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" { + if clusterIdRaw, ok := host["clusterId"]; ok && clusterIdRaw != nil { + clusterId := toInt(clusterIdRaw) + if clusterId > 0 { + clusterNameToId[clusterName] = clusterId + } + } } } } - } - // Fetch details for each cluster that has hosts - clustersWithDetails := make([]map[string]any, 0) - for clusterName := range hostsByCluster { - clusterId, found := clusterNameToId[clusterName] - if !found { - // Try to find clusterId from clustersList by name - for _, cluster := range clustersList { - if name, ok := cluster["name"].(string); ok && name == clusterName { - if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil { - clusterId = toInt(idRaw) - break + // Fetch details for clusters + clustersWithDetails := make([]map[string]any, 0) + // If we have hosts, fetch details for clusters that have hosts + if includeHosts && len(hostsByCluster) > 0 { + for clusterName := range hostsByCluster { + clusterId, found := clusterNameToId[clusterName] + if !found { + // Try to find clusterId from clustersList by name + for _, cluster := range clustersList { + if name, ok := cluster["name"].(string); ok && name == clusterName { + if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil { + clusterId = toInt(idRaw) + break + } + } } } - } - } - if clusterId > 0 { - clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId) - var clusterDetail map[string]any - if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil { - // Format drsStatus - if drsStatus, ok := clusterDetail["drsStatus"].(string); ok { - if drsStatus == "enabled" { - clusterDetail["drsStatusFormatted"] = "🟢 enabled" - } else { - clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus + if clusterId > 0 { + clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId) + var clusterDetail map[string]any + if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil { + // Format drsStatus + if drsStatus, ok := clusterDetail["drsStatus"].(string); ok { + if drsStatus == "enabled" { + clusterDetail["drsStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus + } + } + // Format haStatus + if haStatus, ok := clusterDetail["haStatus"].(string); ok { + if haStatus == "enabled" { + clusterDetail["haStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["haStatusFormatted"] = "🔴 " + haStatus + } + } + // Remove unwanted fields + delete(clusterDetail, "vmwareClusterId") + delete(clusterDetail, "autoscale") + clustersWithDetails = append(clustersWithDetails, clusterDetail) } } - // Format haStatus - if haStatus, ok := clusterDetail["haStatus"].(string); ok { - if haStatus == "enabled" { - clusterDetail["haStatusFormatted"] = "🟢 enabled" - } else { - clusterDetail["haStatusFormatted"] = "🔴 " + haStatus + } + } else { + // If no hosts, fetch details for all clusters from clustersList + for _, cluster := range clustersList { + if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil { + clusterId := toInt(idRaw) + if clusterId > 0 { + clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId) + var clusterDetail map[string]any + if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil { + // Format drsStatus + if drsStatus, ok := clusterDetail["drsStatus"].(string); ok { + if drsStatus == "enabled" { + clusterDetail["drsStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus + } + } + // Format haStatus + if haStatus, ok := clusterDetail["haStatus"].(string); ok { + if haStatus == "enabled" { + clusterDetail["haStatusFormatted"] = "🟢 enabled" + } else { + clusterDetail["haStatusFormatted"] = "🔴 " + haStatus + } + } + // Remove unwanted fields + delete(clusterDetail, "vmwareClusterId") + delete(clusterDetail, "autoscale") + clustersWithDetails = append(clustersWithDetails, clusterDetail) + } } } - // Remove unwanted fields - delete(clusterDetail, "vmwareClusterId") - delete(clusterDetail, "autoscale") - clustersWithDetails = append(clustersWithDetails, clusterDetail) } } + if len(clustersWithDetails) > 0 { + object["clusters"] = clustersWithDetails + } } - object["clusters"] = clustersWithDetails - // Fetch local filers (datacenter level) - localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(args[1])) - localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "") - if err != nil { - display.OutputError(&flags.OutputFormatConfig, "error fetching local filers for %s: %s", args[1], err) - return - } + // Fetch local filers (datacenter level) if requested or if we need totals + var allFilers []map[string]any + if includeFilers || includeTotals { + localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(args[1])) + localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "error fetching local filers for %s: %s", args[1], err) + return + } - // Fetch global filers (service level) - globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0])) - globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "") - if err != nil { - // If error, just continue with empty list - globalFilers = []map[string]any{} - } + // Fetch global filers (service level) + globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0])) + globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "") + if err != nil { + // If error, just continue with empty list + globalFilers = []map[string]any{} + } - // Combine both lists - allFilers := make([]map[string]any, 0, len(localFilers)+len(globalFilers)) - allFilers = append(allFilers, localFilers...) - allFilers = append(allFilers, globalFilers...) + // Combine both lists + allFilers = make([]map[string]any, 0, len(localFilers)+len(globalFilers)) + allFilers = append(allFilers, localFilers...) + allFilers = append(allFilers, globalFilers...) - // Enrich filers with formatted data - for i := range allFilers { - // Set visibility based on source - if i < len(localFilers) { - allFilers[i]["visibility"] = "Local" - } else { - allFilers[i]["visibility"] = "Global" - } - // Format size - sizeStr := "" - if size, ok := allFilers[i]["size"].(map[string]any); ok { - if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { - sizeValue := toFloat64(sizeValueRaw) - if sizeValue > 0 { - sizeStr = fmt.Sprintf("%.0f", sizeValue) - if sizeUnit, ok := size["unit"].(string); ok { - sizeStr += " " + sizeUnit + // Enrich filers with formatted data + for i := range allFilers { + // Set visibility based on source + if i < len(localFilers) { + allFilers[i]["visibility"] = "Local" + } else { + allFilers[i]["visibility"] = "Global" + } + // Format size + sizeStr := "" + if size, ok := allFilers[i]["size"].(map[string]any); ok { + if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { + sizeValue := toFloat64(sizeValueRaw) + if sizeValue > 0 { + sizeStr = fmt.Sprintf("%.0f", sizeValue) + if sizeUnit, ok := size["unit"].(string); ok { + sizeStr += " " + sizeUnit + } } } } - } - allFilers[i]["sizeFormatted"] = sizeStr - - // Format spaceFree - spaceFreeStr := "" - if spaceFreeRaw, ok := allFilers[i]["spaceFree"]; ok && spaceFreeRaw != nil { - spaceFreeValue := toFloat64(spaceFreeRaw) - if spaceFreeValue > 0 { - spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue) + allFilers[i]["sizeFormatted"] = sizeStr + + // Format spaceFree + spaceFreeStr := "" + if spaceFreeRaw, ok := allFilers[i]["spaceFree"]; ok && spaceFreeRaw != nil { + spaceFreeValue := toFloat64(spaceFreeRaw) + if spaceFreeValue > 0 { + spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue) + } } - } - allFilers[i]["spaceFreeFormatted"] = spaceFreeStr - - // Extract cluster name from master (first part of domain) - clusterName := "" - if master, ok := allFilers[i]["master"].(string); ok && master != "" { - // Extract first part before first dot - parts := strings.Split(master, ".") - if len(parts) > 0 { - clusterName = parts[0] + allFilers[i]["spaceFreeFormatted"] = spaceFreeStr + + // Extract cluster name from master (first part of domain) + clusterName := "" + if master, ok := allFilers[i]["master"].(string); ok && master != "" { + // Extract first part before first dot + parts := strings.Split(master, ".") + if len(parts) > 0 { + clusterName = parts[0] + } } - } - allFilers[i]["clusterName"] = clusterName + allFilers[i]["clusterName"] = clusterName - // Add VM count - if vmTotalRaw, ok := allFilers[i]["vmTotal"]; ok && vmTotalRaw != nil { - allFilers[i]["vmCount"] = toInt(vmTotalRaw) - } else { - allFilers[i]["vmCount"] = 0 - } + // Add VM count + if vmTotalRaw, ok := allFilers[i]["vmTotal"]; ok && vmTotalRaw != nil { + allFilers[i]["vmCount"] = toInt(vmTotalRaw) + } else { + allFilers[i]["vmCount"] = 0 + } - // Add connection state indicator - connectionStateIndicator := "🔴" - if connectionState, ok := allFilers[i]["connectionState"].(string); ok && connectionState == "online" { - connectionStateIndicator = "🟢" + // Add connection state indicator + connectionStateIndicator := "🔴" + if connectionState, ok := allFilers[i]["connectionState"].(string); ok && connectionState == "online" { + connectionStateIndicator = "🟢" + } + allFilers[i]["connectionStateIndicator"] = connectionStateIndicator } - allFilers[i]["connectionStateIndicator"] = connectionStateIndicator - } - object["filers"] = allFilers - - // Calculate totals - totalCores := 0 - totalRAM := 0.0 - totalVMs := 0 - totalDiskSpace := 0.0 - - // Sum from hosts - for _, host := range hosts { - // Sum cores - if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { - totalCores += int(toFloat64(cpuNumRaw)) + if includeFilers && len(allFilers) > 0 { + object["filers"] = allFilers } + } + + // Calculate totals only if requested + if includeTotals { + totalCores := 0 + totalRAM := 0.0 + totalVMs := 0 + totalDiskSpace := 0.0 + + // Sum from hosts + for _, host := range hosts { + // Sum cores + if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { + totalCores += int(toFloat64(cpuNumRaw)) + } - // Sum RAM - if ram, ok := host["ram"].(map[string]any); ok { - if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { - totalRAM += toFloat64(ramValueRaw) + // Sum RAM + if ram, ok := host["ram"].(map[string]any); ok { + if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { + totalRAM += toFloat64(ramValueRaw) + } } - } - // Sum VMs - if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - totalVMs += toInt(vmTotalRaw) + // Sum VMs + if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { + totalVMs += toInt(vmTotalRaw) + } } - } - // Sum disk space from filers - for _, filer := range allFilers { - if size, ok := filer["size"].(map[string]any); ok { - if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { - sizeValue := toFloat64(sizeValueRaw) - // Convert to GB if needed - if sizeUnit, ok := size["unit"].(string); ok { - if strings.ToUpper(sizeUnit) == "TB" { - sizeValue *= 1000 // Convert TB to GB - } else if strings.ToUpper(sizeUnit) == "MB" { - sizeValue /= 1000 // Convert MB to GB + // Sum disk space from filers + for _, filer := range allFilers { + if size, ok := filer["size"].(map[string]any); ok { + if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { + sizeValue := toFloat64(sizeValueRaw) + // Convert to GB if needed + if sizeUnit, ok := size["unit"].(string); ok { + if strings.ToUpper(sizeUnit) == "TB" { + sizeValue *= 1000 // Convert TB to GB + } else if strings.ToUpper(sizeUnit) == "MB" { + sizeValue /= 1000 // Convert MB to GB + } } + totalDiskSpace += sizeValue } - totalDiskSpace += sizeValue } } - } - // Format totals - object["totalCores"] = totalCores - object["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM) - object["totalVMs"] = totalVMs - object["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace) + // Format totals + object["totalCores"] = totalCores + object["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM) + object["totalVMs"] = totalVMs + object["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace) + } display.OutputObject(object, args[1], datacenterTemplate, &flags.OutputFormatConfig) } diff --git a/internal/services/dedicatedcloud/templates/datacenter.tmpl b/internal/services/dedicatedcloud/templates/datacenter.tmpl index d5ec216e..57d2e002 100644 --- a/internal/services/dedicatedcloud/templates/datacenter.tmpl +++ b/internal/services/dedicatedcloud/templates/datacenter.tmpl @@ -18,20 +18,18 @@ {{end}}{{if index .Result "totalDiskSpace"}}**Total Disk Space**: {{index .Result "totalDiskSpace"}} {{end}} +{{if index .Result "clusters"}} ## Clusters -{{if index .Result "clusters"}} | ID | Name | DRS Status | DRS Mode | HA Status | EVC Mode | | --- | --- | --- | --- | --- | --- | {{ range index .Result "clusters" }}| {{index . "clusterId"}} | {{index . "name"}} | {{index . "drsStatusFormatted"}} | {{index . "drsMode"}} | {{index . "haStatusFormatted"}} | {{index . "evcMode"}} | {{end}} -{{else}} -No clusters found {{end}} +{{if index .Result "hostsByCluster"}} ## Hosts -{{if index .Result "hostsByCluster"}} {{ range $clusterName, $hosts := (index .Result "hostsByCluster") }} ### Cluster: {{$clusterName}} @@ -40,19 +38,15 @@ No clusters found {{ range $hosts }}| {{index . "hostId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "maintenanceStatus"}} | {{index . "coreNumber"}} | {{if index . "ram"}}{{index . "ram" "value"}}{{if index . "ram" "unit"}} {{index . "ram" "unit"}}{{end}}{{end}} | {{index . "profile"}} | {{index . "vmCount"}} | {{index . "billingType"}} | {{end}} {{end}} -{{else}} -No hosts found {{end}} +{{if index .Result "filers"}} ## Filers -{{if index .Result "filers"}} | ID | Name | Connection | State | Size | Space Free | Cluster | VMs | Billing | Active Node | Visibility | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | {{ range index .Result "filers" }}| {{index . "filerId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "state"}} | {{index . "sizeFormatted"}} | {{index . "spaceFreeFormatted"}} | {{index . "clusterName"}} | {{index . "vmCount"}} | {{index . "billingType"}} | {{index . "activeNode"}} | {{index . "visibility"}} | {{end}} -{{else}} -No filers found {{end}} 💡 Use option --json or --yaml to get the raw output with all information From 336d0cb5880d7e055cf0e4ed4a608369161039cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 17:33:38 +0100 Subject: [PATCH 10/20] refactor: simplify loop in dedicatedcloud hosts processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'for i := range hosts' with 'for _, host := range hosts' - Use 'host' directly instead of 'hosts[i]' for better readability - Add json.Number case to switch statements for RAM and VM totals - Update test expectations for datacenter get command Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- .idea/.gitignore | 8 +++ .idea/modules.xml | 8 +++ .idea/ovhcloud-cli.iml | 9 +++ .idea/vcs.xml | 6 ++ internal/cmd/dedicatedcloud_test.go | 10 ++- .../services/dedicatedcloud/dedicatedcloud.go | 72 +++++++++++++------ 6 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/ovhcloud-cli.iml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..a5563f63 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/ovhcloud-cli.iml b/.idea/ovhcloud-cli.iml new file mode 100644 index 00000000..5e764c4f --- /dev/null +++ b/.idea/ovhcloud-cli.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/internal/cmd/dedicatedcloud_test.go b/internal/cmd/dedicatedcloud_test.go index 7ac28d48..5f279685 100644 --- a/internal/cmd/dedicatedcloud_test.go +++ b/internal/cmd/dedicatedcloud_test.go @@ -291,10 +291,8 @@ func (ms *MockSuite) TestDedicatedCloudDatacenterGetCmd(assert, require *td.T) { require.CmpNoError(err) assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1")) - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Cluster1")) - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("172.17")) // IP address (may be split across lines) - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("93GHz")) // CPU frequency - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("1557")) // Filer ID - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Local")) - assert.Cmp(cleanWhitespacesHelper(out), td.Contains("urn:v1:eu:resource:pccVMware:pcc-12345")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total CPU (Cores)")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total RAM")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total VMs")) + assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total Disk Space")) } diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 52e4e349..50f2d841 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -225,14 +225,28 @@ func ListDatacenter(_ *cobra.Command, args []string) { // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { - totalRAM += toFloat64(ramValueRaw) + if ramValue, ok := ram["value"]; ok && ramValue != nil { + switch v := ramValue.(type) { + case float64: + totalRAM += v + case json.Number: + if f, err := v.Float64(); err == nil { + totalRAM += f + } + } } } // Sum VMs - if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - totalVMs += toInt(vmTotalRaw) + if vmTotal, ok := host["vmTotal"]; ok && vmTotal != nil { + switch v := vmTotal.(type) { + case float64: + totalVMs += int(v) + case json.Number: + if i, err := v.Int64(); err == nil { + totalVMs += int(i) + } + } } } @@ -313,14 +327,14 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ // Enrich hosts with formatted data and group by cluster hostsByCluster := make(map[string][]map[string]any) if includeHosts { - for i := range hosts { + for _, host := range hosts { // Format Core Number with GHz in parentheses coreNumber := "" - if cpuNumRaw, ok := hosts[i]["cpuNum"]; ok && cpuNumRaw != nil { + if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { cpuNumValue := toFloat64(cpuNumRaw) if cpuNumValue > 0 { coreNumber = fmt.Sprintf("%.0f", cpuNumValue) - if cpu, ok := hosts[i]["cpu"].(map[string]any); ok { + if cpu, ok := host["cpu"].(map[string]any); ok { if cpuValueRaw, ok := cpu["value"]; ok && cpuValueRaw != nil { cpuValue := toFloat64(cpuValueRaw) if cpuValue > 0 { @@ -331,35 +345,35 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ } } } - hosts[i]["coreNumber"] = coreNumber + host["coreNumber"] = coreNumber // Add VM count - if vmTotalRaw, ok := hosts[i]["vmTotal"]; ok && vmTotalRaw != nil { - hosts[i]["vmCount"] = toInt(vmTotalRaw) + if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { + host["vmCount"] = toInt(vmTotalRaw) } else { - hosts[i]["vmCount"] = 0 + host["vmCount"] = 0 } // Add maintenance status emoji - if inMaintenance, ok := hosts[i]["inMaintenance"].(bool); ok && inMaintenance { - hosts[i]["maintenanceStatus"] = "🔧" + if inMaintenance, ok := host["inMaintenance"].(bool); ok && inMaintenance { + host["maintenanceStatus"] = "🔧" } else { - hosts[i]["maintenanceStatus"] = "✅" + host["maintenanceStatus"] = "✅" } // Add connection state indicator connectionStateIndicator := "🔴" - if connectionState, ok := hosts[i]["connectionState"].(string); ok && connectionState == "connected" { + if connectionState, ok := host["connectionState"].(string); ok && connectionState == "connected" { connectionStateIndicator = "🟢" } - hosts[i]["connectionStateIndicator"] = connectionStateIndicator + host["connectionStateIndicator"] = connectionStateIndicator // Group by cluster clusterName := "Unknown" - if cn, ok := hosts[i]["clusterName"].(string); ok && cn != "" { + if cn, ok := host["clusterName"].(string); ok && cn != "" { clusterName = cn } - hostsByCluster[clusterName] = append(hostsByCluster[clusterName], hosts[i]) + hostsByCluster[clusterName] = append(hostsByCluster[clusterName], host) } if len(hostsByCluster) > 0 { object["hostsByCluster"] = hostsByCluster @@ -576,14 +590,28 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValueRaw, ok := ram["value"]; ok && ramValueRaw != nil { - totalRAM += toFloat64(ramValueRaw) + if ramValue, ok := ram["value"]; ok && ramValue != nil { + switch v := ramValue.(type) { + case float64: + totalRAM += v + case json.Number: + if f, err := v.Float64(); err == nil { + totalRAM += f + } + } } } // Sum VMs - if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - totalVMs += toInt(vmTotalRaw) + if vmTotal, ok := host["vmTotal"]; ok && vmTotal != nil { + switch v := vmTotal.(type) { + case float64: + totalVMs += int(v) + case json.Number: + if i, err := v.Int64(); err == nil { + totalVMs += int(i) + } + } } } From b04b48eadaad6a02f672dd4837f6699931919214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 17:51:04 +0100 Subject: [PATCH 11/20] refactor: simplify loops in dedicatedcloud using range with value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'for i := range hosts' with 'for _, host := range hosts' - Replace 'for i := range allFilers' with 'for i, filer := range allFilers' - Use switch statement for clusterId type assertion - Use direct variable references instead of array indexing for better readability Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- .../services/dedicatedcloud/dedicatedcloud.go | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 50f2d841..05221325 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -395,11 +395,23 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ if includeHosts { for _, host := range hosts { if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" { - if clusterIdRaw, ok := host["clusterId"]; ok && clusterIdRaw != nil { - clusterId := toInt(clusterIdRaw) + switch v := host["clusterId"].(type) { + case float64: + clusterId := int(v) if clusterId > 0 { clusterNameToId[clusterName] = clusterId } + case int: + if v > 0 { + clusterNameToId[clusterName] = v + } + case json.Number: + if i, err := v.Int64(); err == nil { + clusterId := int(i) + if clusterId > 0 { + clusterNameToId[clusterName] = clusterId + } + } } } } @@ -512,16 +524,16 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ allFilers = append(allFilers, globalFilers...) // Enrich filers with formatted data - for i := range allFilers { + for i, filer := range allFilers { // Set visibility based on source if i < len(localFilers) { - allFilers[i]["visibility"] = "Local" + filer["visibility"] = "Local" } else { - allFilers[i]["visibility"] = "Global" + filer["visibility"] = "Global" } // Format size sizeStr := "" - if size, ok := allFilers[i]["size"].(map[string]any); ok { + if size, ok := filer["size"].(map[string]any); ok { if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil { sizeValue := toFloat64(sizeValueRaw) if sizeValue > 0 { @@ -532,42 +544,42 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ } } } - allFilers[i]["sizeFormatted"] = sizeStr + filer["sizeFormatted"] = sizeStr // Format spaceFree spaceFreeStr := "" - if spaceFreeRaw, ok := allFilers[i]["spaceFree"]; ok && spaceFreeRaw != nil { + if spaceFreeRaw, ok := filer["spaceFree"]; ok && spaceFreeRaw != nil { spaceFreeValue := toFloat64(spaceFreeRaw) if spaceFreeValue > 0 { spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue) } } - allFilers[i]["spaceFreeFormatted"] = spaceFreeStr + filer["spaceFreeFormatted"] = spaceFreeStr // Extract cluster name from master (first part of domain) clusterName := "" - if master, ok := allFilers[i]["master"].(string); ok && master != "" { + if master, ok := filer["master"].(string); ok && master != "" { // Extract first part before first dot parts := strings.Split(master, ".") if len(parts) > 0 { clusterName = parts[0] } } - allFilers[i]["clusterName"] = clusterName + filer["clusterName"] = clusterName // Add VM count - if vmTotalRaw, ok := allFilers[i]["vmTotal"]; ok && vmTotalRaw != nil { - allFilers[i]["vmCount"] = toInt(vmTotalRaw) + if vmTotalRaw, ok := filer["vmTotal"]; ok && vmTotalRaw != nil { + filer["vmCount"] = toInt(vmTotalRaw) } else { - allFilers[i]["vmCount"] = 0 + filer["vmCount"] = 0 } // Add connection state indicator connectionStateIndicator := "🔴" - if connectionState, ok := allFilers[i]["connectionState"].(string); ok && connectionState == "online" { + if connectionState, ok := filer["connectionState"].(string); ok && connectionState == "online" { connectionStateIndicator = "🟢" } - allFilers[i]["connectionStateIndicator"] = connectionStateIndicator + filer["connectionStateIndicator"] = connectionStateIndicator } if includeFilers && len(allFilers) > 0 { object["filers"] = allFilers From dcce83b437936729c2a1556051d229b833a47d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 17:57:22 +0100 Subject: [PATCH 12/20] refactor: use switch statement for vmTotal type assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace if statements with switch for host and filer vmTotal - Handle float64, int, and json.Number types consistently - Improve code consistency across dedicatedcloud service Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- .../services/dedicatedcloud/dedicatedcloud.go | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 05221325..b51f066b 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -348,9 +348,18 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ host["coreNumber"] = coreNumber // Add VM count - if vmTotalRaw, ok := host["vmTotal"]; ok && vmTotalRaw != nil { - host["vmCount"] = toInt(vmTotalRaw) - } else { + switch v := host["vmTotal"].(type) { + case float64: + host["vmCount"] = int(v) + case int: + host["vmCount"] = v + case json.Number: + if i, err := v.Int64(); err == nil { + host["vmCount"] = int(i) + } else { + host["vmCount"] = 0 + } + default: host["vmCount"] = 0 } @@ -568,9 +577,18 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ filer["clusterName"] = clusterName // Add VM count - if vmTotalRaw, ok := filer["vmTotal"]; ok && vmTotalRaw != nil { - filer["vmCount"] = toInt(vmTotalRaw) - } else { + switch v := filer["vmTotal"].(type) { + case float64: + filer["vmCount"] = int(v) + case int: + filer["vmCount"] = v + case json.Number: + if i, err := v.Int64(); err == nil { + filer["vmCount"] = int(i) + } else { + filer["vmCount"] = 0 + } + default: filer["vmCount"] = 0 } From 40a3b5f24052d8f0633510ae1e0fa2b96bd101cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:04:47 +0100 Subject: [PATCH 13/20] refactor: simplify cpuNum conversion using toInt directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace int(toFloat64(cpuNumRaw)) with toInt(cpuNumRaw) - Simplify code by using dedicated toInt function instead of double conversion - Apply to both ListDatacenter and getDatacenterWithOptions functions Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- internal/services/dedicatedcloud/dedicatedcloud.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index b51f066b..3b0e8b49 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -220,7 +220,7 @@ func ListDatacenter(_ *cobra.Command, args []string) { for _, host := range hosts { // Sum cores if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { - totalCores += int(toFloat64(cpuNumRaw)) + totalCores += toInt(cpuNumRaw) } // Sum RAM @@ -615,7 +615,7 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ for _, host := range hosts { // Sum cores if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil { - totalCores += int(toFloat64(cpuNumRaw)) + totalCores += toInt(cpuNumRaw) } // Sum RAM From 0c41767067880c78ee1f48a9b96e0555e4803a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:11:05 +0100 Subject: [PATCH 14/20] refactor: use switch statement in toInt function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace multiple if statements with switch statement - Improve code consistency with rest of dedicatedcloud service - Handle int, float64, and json.Number types consistently Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- internal/services/dedicatedcloud/dedicatedcloud.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 3b0e8b49..6ec03f3e 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -46,13 +46,12 @@ func toFloat64(v any) float64 { // toInt converts any numeric type to int func toInt(v any) int { - if i, ok := v.(int); ok { - return i - } - if f, ok := v.(float64); ok { - return int(f) - } - if n, ok := v.(json.Number); ok { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + case json.Number: if i, err := n.Int64(); err == nil { return int(i) } From 1594d49397eb674ed6a14cb0bb29f4ace948e90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:14:58 +0100 Subject: [PATCH 15/20] refactor: use switch statement in toFloat64 function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace multiple if statements with switch statement - Improve code consistency with toInt function - Handle int, float64, and json.Number types consistently Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- internal/services/dedicatedcloud/dedicatedcloud.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index 6ec03f3e..cac06f22 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -30,13 +30,12 @@ var ( // toFloat64 converts any numeric type to float64 func toFloat64(v any) float64 { - if f, ok := v.(float64); ok { - return f - } - if i, ok := v.(int); ok { - return float64(i) - } - if n, ok := v.(json.Number); ok { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case json.Number: if f, err := n.Float64(); err == nil { return f } From 24bb1074207af500e90bdf5e964a04082ebcf67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:16:38 +0100 Subject: [PATCH 16/20] refactor: simplify RAM total calculation using toFloat64 directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace switch statement with direct toFloat64 call - Simplify code by leveraging toFloat64 function that handles all types - Apply to both ListDatacenter and getDatacenterWithOptions functions Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- .../services/dedicatedcloud/dedicatedcloud.go | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go index cac06f22..9a1ae694 100644 --- a/internal/services/dedicatedcloud/dedicatedcloud.go +++ b/internal/services/dedicatedcloud/dedicatedcloud.go @@ -223,16 +223,7 @@ func ListDatacenter(_ *cobra.Command, args []string) { // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValue, ok := ram["value"]; ok && ramValue != nil { - switch v := ramValue.(type) { - case float64: - totalRAM += v - case json.Number: - if f, err := v.Float64(); err == nil { - totalRAM += f - } - } - } + totalRAM += toFloat64(ram["value"]) } // Sum VMs @@ -618,16 +609,7 @@ func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includ // Sum RAM if ram, ok := host["ram"].(map[string]any); ok { - if ramValue, ok := ram["value"]; ok && ramValue != nil { - switch v := ramValue.(type) { - case float64: - totalRAM += v - case json.Number: - if f, err := v.Float64(); err == nil { - totalRAM += f - } - } - } + totalRAM += toFloat64(ram["value"]) } // Sum VMs From 4a8aab1564505159e6132b209024bc0099868e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:18:32 +0100 Subject: [PATCH 17/20] test: verify all dedicated cloud tests pass after refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All TestDedicatedCloudDatacenterGetCmd tests pass - All TestDedicatedCloudDatacenterListCmd tests pass - All TestDedicatedCloudGetCmd tests pass - All TestDedicatedCloudListCmd tests pass Signed-off-by: François Loiseau Signed-off-by: François Loiseau From 837045a8e782bd3386556471f34b274462ce6ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Loiseau?= Date: Mon, 17 Nov 2025 18:30:33 +0100 Subject: [PATCH 18/20] fix: update TestCloudInstanceNullImageCmd to match new table formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update expected table format to match WithWordWrap(0) behavior - Table columns are now more compact due to word wrap disabled - All tests now pass Signed-off-by: François Loiseau Signed-off-by: François Loiseau --- internal/cmd/cloud_instance_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/cloud_instance_test.go b/internal/cmd/cloud_instance_test.go index caf23af1..819d01f1 100644 --- a/internal/cmd/cloud_instance_test.go +++ b/internal/cmd/cloud_instance_test.go @@ -105,10 +105,10 @@ func (ms *MockSuite) TestCloudInstanceNullImageCmd(assert, require *td.T) { IP addresses: - IP | Type | Gateway IP - ------------------------|------------------------|------------------------ - 1.2.3.4 | public | 1.2.3.4 - 2001:db8::1 | public | 2001:db8::ff + IP | Type | Gateway IP + -------------|--------|-------------- + 1.2.3.4 | public | 1.2.3.4 + 2001:db8::1 | public | 2001:db8::ff ## Flavor details From f6ec6f1beb598dbacb046927cf2299ccd599738f Mon Sep 17 00:00:00 2001 From: fluatovh <76485062+fluatovh@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:46:05 +0100 Subject: [PATCH 19/20] Update display.go removed unused variable Signed-off-by: fluatovh <76485062+fluatovh@users.noreply.github.com> --- internal/display/display.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/display/display.go b/internal/display/display.go index fc92f63c..eff75583 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -273,15 +273,6 @@ func OutputObject(value map[string]any, serviceName, templateContent string, out exitError("failed to execute template: %s", err) } - // Define word wrap for the renderer. - // Use 80 characters by default, or the terminal width if available. - wordWrap := 80 - if termFd := os.Stdout.Fd(); term.IsTerminal(termFd) { - if termWidth, _, _ := term.GetSize(termFd); termWidth > 0 { - wordWrap = termWidth - } - } - r, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithPreservedNewLines(), From 8c825f57c3483f1ddd078f6cb7ab297a0439bf4e Mon Sep 17 00:00:00 2001 From: fluatovh <76485062+fluatovh@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:09:02 +0100 Subject: [PATCH 20/20] Update display.go fixing conflict Signed-off-by: fluatovh <76485062+fluatovh@users.noreply.github.com> --- internal/display/display.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/display/display.go b/internal/display/display.go index eff75583..92c8cacb 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -22,7 +22,6 @@ import ( "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/charmbracelet/x/term" "github.com/ghodss/yaml" "github.com/ovh/ovhcloud-cli/internal/filters" "gopkg.in/ini.v1"