From 67ae0c5ed96f325e11bfc85100343e5fb2767a44 Mon Sep 17 00:00:00 2001 From: hbazzi Date: Tue, 25 Aug 2026 23:58:09 -0400 Subject: [PATCH 1/2] feat: add resource pool CLI support --- cmd/instances/deploy.go | 85 ++++++++++++++++++ cmd/pools.go | 191 ++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 7 ++ pkg/api/deploy.go | 17 ++++ 4 files changed, 300 insertions(+) create mode 100644 cmd/pools.go diff --git a/cmd/instances/deploy.go b/cmd/instances/deploy.go index a932656..e7a5ee4 100644 --- a/cmd/instances/deploy.go +++ b/cmd/instances/deploy.go @@ -27,6 +27,7 @@ var ( sshKeyFlag string billingCycleFlag string promoFlag string + poolFlag string yesFlag bool jsonFlag bool ) @@ -57,6 +58,9 @@ Examples: # Apply a promo code odo instances deploy --promo LETCLI + # Create a $0 VM inside a Hostodo capacity subscription + odo instances deploy --pool pool::abc123 --os "Ubuntu 24.04" --region DET01 --plan EPYC-2G1C32GN --hostname web-1 --yes + # JSON output (requires all selection flags) odo instances deploy --os "Ubuntu 22.04" --region "Los Angeles" --plan KVM-2G --json`, RunE: runDeploy, @@ -70,6 +74,7 @@ func init() { DeployCmd.Flags().StringVar(&sshKeyFlag, "ssh-key", "", "SSH key name to use for authentication") DeployCmd.Flags().StringVar(&billingCycleFlag, "billing-cycle", "", "Billing cycle (monthly, annually, semiannually, biennially, triennially)") DeployCmd.Flags().StringVar(&promoFlag, "promo", "", "Promo code for a discount") + DeployCmd.Flags().StringVar(&poolFlag, "pool", "", "Create a $0 VM inside a Hostodo capacity pool_id") DeployCmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Skip confirmation prompt") DeployCmd.Flags().BoolVar(&jsonFlag, "json", false, "JSON output mode (requires --os, --region, --plan)") } @@ -91,6 +96,10 @@ func runDeploy(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create API client: %w", err) } + if poolFlag != "" { + return runDeployInPool(client) + } + // Fetch available options if !jsonFlag { fmt.Println("Loading available options...") @@ -947,3 +956,79 @@ func mapEventMessage(msg string) string { return "" } } + +// runDeployInPool creates a $0 capacity VM (no order/invoice/payment). +func runDeployInPool(client *api.Client) error { + if osFlag == "" || regionFlag == "" || planFlag == "" || hostnameFlag == "" { + return fmt.Errorf("--pool requires --os, --region, --plan, and --hostname") + } + + templates, err := client.ListTemplates() + if err != nil { + return fmt.Errorf("failed to load OS templates: %w", err) + } + regions, err := client.ListRegions() + if err != nil { + return fmt.Errorf("failed to load regions: %w", err) + } + plans, err := client.ListPlans() + if err != nil { + return fmt.Errorf("failed to load plans: %w", err) + } + + selectedTemplate, err := selectTemplate(templates, osFlag, true) + if err != nil { + return err + } + selectedRegion, err := selectRegion(regions, regionFlag, true) + if err != nil { + return err + } + var selectedPlan *api.Plan + for i := range plans { + if strings.EqualFold(plans[i].Name, planFlag) { + selectedPlan = &plans[i] + break + } + } + if selectedPlan == nil { + return fmt.Errorf("plan not found: %s", planFlag) + } + + req := map[string]interface{}{ + "pool_id": poolFlag, + "hostname": hostnameFlag, + "region_id": selectedRegion.ID, + "template_id": selectedTemplate.ID, + "plan_id": selectedPlan.ID, + } + if sshKeyFlag != "" { + keys, err := client.ListSSHKeys() + if err != nil { + return err + } + for _, k := range keys { + if strings.EqualFold(k.Name, sshKeyFlag) { + req["ssh_key_id"] = k.ID + break + } + } + } + + if !yesFlag && !jsonFlag { + fmt.Printf("Create VM %s in capacity %s for $0? [y/N] ", hostnameFlag, poolFlag) + var answer string + fmt.Scanln(&answer) + if strings.ToLower(strings.TrimSpace(answer)) != "y" && strings.ToLower(strings.TrimSpace(answer)) != "yes" { + return fmt.Errorf("cancelled") + } + } + + out, err := client.CreatePoolVM(req) + if err != nil { + return err + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} diff --git a/cmd/pools.go b/cmd/pools.go new file mode 100644 index 0000000..a6176ae --- /dev/null +++ b/cmd/pools.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/auth" + "github.com/hostodo/odo-cli/v2/pkg/config" + "github.com/spf13/cobra" +) + +var ( + poolsJSON bool + poolsSimple bool +) + +type poolPlanSummary struct { + Name string `json:"name"` +} + +type poolSummary struct { + ID string `json:"id"` + PoolID string `json:"pool_id"` + Status string `json:"status"` + PlanName string `json:"plan_name"` + Plan *poolPlanSummary `json:"plan"` + UsedRAMMB int `json:"used_ram_mb"` + TotalRAMMB int `json:"total_ram_mb"` + UsedDiskGB int `json:"used_disk_gb"` + TotalDiskGB int `json:"total_disk_gb"` + UsedInstances int `json:"used_instances"` + MaxInstances int `json:"max_instances"` +} + +type poolsListResponse struct { + Count int `json:"count"` + Results []poolSummary `json:"results"` +} + +var poolsCmd = &cobra.Command{ + Use: "pools", + Short: "Manage Hostodo capacity subscriptions", + Long: "List and inspect Hostodo capacity subscriptions (resource pools).", +} + +var poolsListCmd = &cobra.Command{ + Use: "list", + Short: "List capacity subscriptions", + RunE: func(cmd *cobra.Command, args []string) error { + return runPoolsList() + }, +} + +var poolsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a capacity subscription", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPoolsShow(args[0]) + }, +} + +func init() { + poolsCmd.PersistentFlags().BoolVar(&poolsJSON, "json", false, "JSON output") + poolsCmd.PersistentFlags().BoolVar(&poolsSimple, "simple", false, "simple table output") + poolsCmd.AddCommand(poolsListCmd) + poolsCmd.AddCommand(poolsShowCmd) +} + +func poolsClient() (*api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + if !auth.IsAuthenticated() { + return nil, api.ErrNotAuthenticated + } + return api.NewClient(cfg) +} + +func runPoolsList() error { + client, err := poolsClient() + if err != nil { + return err + } + resp, err := client.Get("/client/resource-pools/") + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + if poolsSimple && !poolsJSON { + var payload poolsListResponse + if err := json.Unmarshal(body, &payload); err != nil { + return err + } + printPoolsSimple(payload.Results) + return nil + } + return printPrettyJSON(body) +} + +func runPoolsShow(poolID string) error { + client, err := poolsClient() + if err != nil { + return err + } + resp, err := client.Get("/client/resource-pools/" + poolID + "/") + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + if poolsSimple && !poolsJSON { + var pool poolSummary + if err := json.Unmarshal(body, &pool); err != nil { + return err + } + printPoolsSimple([]poolSummary{pool}) + return nil + } + return printPrettyJSON(body) +} + +func printPrettyJSON(body []byte) error { + var out bytes.Buffer + if err := json.Indent(&out, body, "", " "); err != nil { + return err + } + out.WriteByte('\n') + _, err := out.WriteTo(os.Stdout) + return err +} + +func printPoolsSimple(pools []poolSummary) { + if len(pools) == 0 { + fmt.Println("No capacity subscriptions found.") + return + } + fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n", "POOL", "STATUS", "PLAN", "RAM_MB", "DISK_GB", "VMS") + for _, pool := range pools { + fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n", + poolIdentifier(pool), + valueOrDash(pool.Status), + poolPlanName(pool), + fmt.Sprintf("%d/%d", pool.UsedRAMMB, pool.TotalRAMMB), + fmt.Sprintf("%d/%d", pool.UsedDiskGB, pool.TotalDiskGB), + fmt.Sprintf("%d/%d", pool.UsedInstances, pool.MaxInstances), + ) + } +} + +func poolIdentifier(pool poolSummary) string { + if pool.PoolID != "" { + return pool.PoolID + } + return valueOrDash(pool.ID) +} + +func poolPlanName(pool poolSummary) string { + if pool.PlanName != "" { + return pool.PlanName + } + if pool.Plan != nil && pool.Plan.Name != "" { + return pool.Plan.Name + } + return "-" +} + +func valueOrDash(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/cmd/root.go b/cmd/root.go index e03d7a7..c8ea089 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,10 @@ Billing: odo invoices # List your invoices odo pay # Pay an invoice +Capacity: + odo pools list # List capacity subscriptions + odo pools show # Show capacity quota and usage + Support: odo tickets list # List support tickets odo tickets open # Open a support ticket @@ -97,6 +101,9 @@ func init() { rootCmd.AddCommand(invoicesCmd) rootCmd.AddCommand(payCmd) + // Capacity subscriptions + rootCmd.AddCommand(poolsCmd) + // Support ticket commands rootCmd.AddCommand(ticketsCmd) diff --git a/pkg/api/deploy.go b/pkg/api/deploy.go index 1394b44..4e5ba5e 100644 --- a/pkg/api/deploy.go +++ b/pkg/api/deploy.go @@ -134,6 +134,23 @@ func (c *Client) CreateDeployOrder(req DeployRequest) (*DeployResponse, error) { return &deployResp, nil } +// CreatePoolVM creates a $0 VM inside a Hostodo capacity subscription. +func (c *Client) CreatePoolVM(req map[string]interface{}) (map[string]interface{}, error) { + path := "/client/instances/create_in_pool/" + + resp, err := c.Post(path, req) + if err != nil { + return nil, err + } + + var out map[string]interface{} + if err := parseResponse(resp, &out); err != nil { + return nil, err + } + + return out, nil +} + // CheckHostnameExists checks if a hostname is already in use func (c *Client) CheckHostnameExists(hostname string) (bool, error) { // Use ListInstances to get all instances and check for hostname collision From 28e17d00ece098b6c7c312e11fb0e4dedda3dd83 Mon Sep 17 00:00:00 2001 From: hbazzi Date: Wed, 26 Aug 2026 00:26:34 -0400 Subject: [PATCH 2/2] fix: address resource pool CLI review --- cmd/instances/deploy.go | 18 +++- cmd/pools.go | 227 +++++++++++++++++++++++++++++++++------- 2 files changed, 203 insertions(+), 42 deletions(-) diff --git a/cmd/instances/deploy.go b/cmd/instances/deploy.go index e7a5ee4..0059d02 100644 --- a/cmd/instances/deploy.go +++ b/cmd/instances/deploy.go @@ -995,9 +995,14 @@ func runDeployInPool(client *api.Client) error { return fmt.Errorf("plan not found: %s", planFlag) } + hostname, err := resolveHostname(client, hostnameFlag) + if err != nil { + return err + } + req := map[string]interface{}{ "pool_id": poolFlag, - "hostname": hostnameFlag, + "hostname": hostname, "region_id": selectedRegion.ID, "template_id": selectedTemplate.ID, "plan_id": selectedPlan.ID, @@ -1007,18 +1012,25 @@ func runDeployInPool(client *api.Client) error { if err != nil { return err } + matched := false for _, k := range keys { if strings.EqualFold(k.Name, sshKeyFlag) { req["ssh_key_id"] = k.ID + matched = true break } } + if !matched { + return fmt.Errorf("SSH key not found: %s", sshKeyFlag) + } } if !yesFlag && !jsonFlag { - fmt.Printf("Create VM %s in capacity %s for $0? [y/N] ", hostnameFlag, poolFlag) + fmt.Printf("Create VM %s in capacity %s for $0? [y/N] ", hostname, poolFlag) var answer string - fmt.Scanln(&answer) + if _, err := fmt.Scanln(&answer); err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } if strings.ToLower(strings.TrimSpace(answer)) != "y" && strings.ToLower(strings.TrimSpace(answer)) != "yes" { return fmt.Errorf("cancelled") } diff --git a/cmd/pools.go b/cmd/pools.go index a6176ae..f27dd20 100644 --- a/cmd/pools.go +++ b/cmd/pools.go @@ -6,45 +6,69 @@ import ( "fmt" "io" "os" + "strings" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/hostodo/odo-cli/v2/pkg/api" "github.com/hostodo/odo-cli/v2/pkg/auth" "github.com/hostodo/odo-cli/v2/pkg/config" + "github.com/hostodo/odo-cli/v2/pkg/ui" "github.com/spf13/cobra" ) var ( - poolsJSON bool - poolsSimple bool + poolsJSON bool + poolsSimple bool + poolsDetails bool ) +// poolPlanSummary is the compact plan shape returned inside pool payloads. type poolPlanSummary struct { Name string `json:"name"` } +// poolSummary is the resource-pool shape needed for CLI list rendering. type poolSummary struct { - ID string `json:"id"` - PoolID string `json:"pool_id"` - Status string `json:"status"` - PlanName string `json:"plan_name"` - Plan *poolPlanSummary `json:"plan"` - UsedRAMMB int `json:"used_ram_mb"` - TotalRAMMB int `json:"total_ram_mb"` - UsedDiskGB int `json:"used_disk_gb"` - TotalDiskGB int `json:"total_disk_gb"` - UsedInstances int `json:"used_instances"` - MaxInstances int `json:"max_instances"` + ID string `json:"id"` + PoolID string `json:"pool_id"` + Status string `json:"status"` + PlanName string `json:"plan_name"` + Plan *poolPlanSummary `json:"plan"` + UsedRAMMB int `json:"used_ram_mb"` + TotalRAMMB int `json:"total_ram_mb"` + UsedDiskGB int `json:"used_disk_gb"` + TotalDiskGB int `json:"total_disk_gb"` + UsedInstances int `json:"used_instances"` + MaxInstances int `json:"max_instances"` + UsedIPv4 int `json:"used_ips"` + MaxIPv4 int `json:"max_ips"` + UsedBandwidth int `json:"used_bandwidth_gb"` + TotalBandwidth int `json:"total_bandwidth_gb"` } +// poolsListResponse is the paginated response from /client/resource-pools/. type poolsListResponse struct { Count int `json:"count"` Results []poolSummary `json:"results"` } +// poolsTableModel renders capacity subscriptions in a small interactive TUI. +type poolsTableModel struct { + table table.Model +} + var poolsCmd = &cobra.Command{ Use: "pools", Short: "Manage Hostodo capacity subscriptions", - Long: "List and inspect Hostodo capacity subscriptions (resource pools).", + Long: `List and inspect Hostodo capacity subscriptions (resource pools). + +Output Formats: + - Interactive TUI (default) - Scrollable capacity table + - JSON (--json) - JSON format for scripting and automation + - Simple (--simple) - Static ASCII table for quick viewing + - Details (--details) - Detailed capacity quota and usage`, } var poolsListCmd = &cobra.Command{ @@ -65,12 +89,14 @@ var poolsShowCmd = &cobra.Command{ } func init() { - poolsCmd.PersistentFlags().BoolVar(&poolsJSON, "json", false, "JSON output") - poolsCmd.PersistentFlags().BoolVar(&poolsSimple, "simple", false, "simple table output") + poolsCmd.PersistentFlags().BoolVar(&poolsJSON, "json", false, "Output as JSON") + poolsCmd.PersistentFlags().BoolVar(&poolsSimple, "simple", false, "Output as simple table") + poolsCmd.PersistentFlags().BoolVar(&poolsDetails, "details", false, "Show detailed information") poolsCmd.AddCommand(poolsListCmd) poolsCmd.AddCommand(poolsShowCmd) } +// poolsClient returns an authenticated Hostodo API client for pool commands. func poolsClient() (*api.Client, error) { cfg, err := config.Load() if err != nil { @@ -82,6 +108,7 @@ func poolsClient() (*api.Client, error) { return api.NewClient(cfg) } +// runPoolsList fetches and renders all capacity subscriptions for the user. func runPoolsList() error { client, err := poolsClient() if err != nil { @@ -99,17 +126,17 @@ func runPoolsList() error { if resp.StatusCode >= 400 { return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) } - if poolsSimple && !poolsJSON { - var payload poolsListResponse - if err := json.Unmarshal(body, &payload); err != nil { - return err - } - printPoolsSimple(payload.Results) - return nil + if poolsJSON { + return printPrettyJSON(body) + } + var payload poolsListResponse + if err := json.Unmarshal(body, &payload); err != nil { + return err } - return printPrettyJSON(body) + return renderPools(payload.Results, payload.Count) } +// runPoolsShow fetches and renders a single capacity subscription by pool_id. func runPoolsShow(poolID string) error { client, err := poolsClient() if err != nil { @@ -127,17 +154,36 @@ func runPoolsShow(poolID string) error { if resp.StatusCode >= 400 { return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) } - if poolsSimple && !poolsJSON { - var pool poolSummary - if err := json.Unmarshal(body, &pool); err != nil { - return err + if poolsJSON { + return printPrettyJSON(body) + } + var pool poolSummary + if err := json.Unmarshal(body, &pool); err != nil { + return err + } + return renderPools([]poolSummary{pool}, 1) +} + +// renderPools chooses the requested pool output mode. +func renderPools(pools []poolSummary, count int) error { + if poolsSimple { + fmt.Print(formatPoolsSimple(pools)) + if len(pools) > 0 { + fmt.Printf("\nTotal: %d capacity subscriptions\n", count) + } + return nil + } + if poolsDetails { + fmt.Print(formatPoolsDetails(pools)) + if len(pools) > 0 { + fmt.Printf("\nTotal: %d capacity subscriptions\n", count) } - printPoolsSimple([]poolSummary{pool}) return nil } - return printPrettyJSON(body) + return runPoolsTUI(pools) } +// printPrettyJSON pretty-prints raw JSON from the API without interface{} unmarshalling. func printPrettyJSON(body []byte) error { var out bytes.Buffer if err := json.Indent(&out, body, "", " "); err != nil { @@ -148,24 +194,114 @@ func printPrettyJSON(body []byte) error { return err } -func printPoolsSimple(pools []poolSummary) { +// formatPoolsSimple formats capacity subscriptions as a static table. +func formatPoolsSimple(pools []poolSummary) string { if len(pools) == 0 { - fmt.Println("No capacity subscriptions found.") - return + return "No capacity subscriptions found.\n" } - fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n", "POOL", "STATUS", "PLAN", "RAM_MB", "DISK_GB", "VMS") + var sb strings.Builder + header := fmt.Sprintf("%-18s %-12s %-14s %-12s %-12s %-8s\n", "POOL", "STATUS", "PLAN", "RAM_MB", "DISK_GB", "VMS") + sb.WriteString(header) + sb.WriteString(strings.Repeat("-", len(strings.TrimSuffix(header, "\n"))) + "\n") for _, pool := range pools { - fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n", - poolIdentifier(pool), - valueOrDash(pool.Status), - poolPlanName(pool), + sb.WriteString(fmt.Sprintf("%-18s %-12s %-14s %-12s %-12s %-8s\n", + truncatePool(poolIdentifier(pool), 18), + truncatePool(valueOrDash(pool.Status), 12), + truncatePool(poolPlanName(pool), 14), fmt.Sprintf("%d/%d", pool.UsedRAMMB, pool.TotalRAMMB), fmt.Sprintf("%d/%d", pool.UsedDiskGB, pool.TotalDiskGB), fmt.Sprintf("%d/%d", pool.UsedInstances, pool.MaxInstances), - ) + )) + } + return sb.String() +} + +// formatPoolsDetails formats capacity subscriptions with quota dimensions. +func formatPoolsDetails(pools []poolSummary) string { + if len(pools) == 0 { + return "No capacity subscriptions found.\n" + } + var sb strings.Builder + for i, pool := range pools { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("Capacity: %s\n", poolIdentifier(pool))) + sb.WriteString(fmt.Sprintf(" Status: %s\n", valueOrDash(pool.Status))) + sb.WriteString(fmt.Sprintf(" Plan: %s\n", poolPlanName(pool))) + sb.WriteString(fmt.Sprintf(" RAM: %d / %d MB\n", pool.UsedRAMMB, pool.TotalRAMMB)) + sb.WriteString(fmt.Sprintf(" Disk: %d / %d GB\n", pool.UsedDiskGB, pool.TotalDiskGB)) + sb.WriteString(fmt.Sprintf(" VMs: %d / %d\n", pool.UsedInstances, pool.MaxInstances)) + sb.WriteString(fmt.Sprintf(" IPv4: %d / %d\n", pool.UsedIPv4, pool.MaxIPv4)) + if pool.TotalBandwidth > 0 || pool.UsedBandwidth > 0 { + sb.WriteString(fmt.Sprintf(" Bandwidth: %d / %d GB\n", pool.UsedBandwidth, pool.TotalBandwidth)) + } + } + return sb.String() +} + +// runPoolsTUI launches the default interactive pool table. +func runPoolsTUI(pools []poolSummary) error { + if len(pools) == 0 { + fmt.Println("No capacity subscriptions found.") + return nil + } + _, err := tea.NewProgram(newPoolsTableModel(pools)).Run() + return err +} + +// newPoolsTableModel builds the Bubble Tea table model for pool rows. +func newPoolsTableModel(pools []poolSummary) poolsTableModel { + columns := []table.Column{ + {Title: "POOL", Width: 18}, + {Title: "STATUS", Width: 12}, + {Title: "PLAN", Width: 14}, + {Title: "RAM", Width: 13}, + {Title: "DISK", Width: 12}, + {Title: "VMS", Width: 8}, + } + rows := make([]table.Row, 0, len(pools)) + for _, pool := range pools { + rows = append(rows, table.Row{ + truncatePool(poolIdentifier(pool), 18), + truncatePool(valueOrDash(pool.Status), 12), + truncatePool(poolPlanName(pool), 14), + fmt.Sprintf("%d/%d MB", pool.UsedRAMMB, pool.TotalRAMMB), + fmt.Sprintf("%d/%d", pool.UsedDiskGB, pool.TotalDiskGB), + fmt.Sprintf("%d/%d", pool.UsedInstances, pool.MaxInstances), + }) + } + t := table.New(table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), table.WithHeight(12)) + styles := table.DefaultStyles() + styles.Header = styles.Header.Bold(true).Foreground(lipgloss.Color("#00D9FF")) + styles.Selected = styles.Selected.Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#8B5CF6")) + t.SetStyles(styles) + return poolsTableModel{table: t} +} + +// Init initializes the interactive pool table model. +func (m poolsTableModel) Init() tea.Cmd { return nil } + +// Update handles keyboard input for the interactive pool table model. +func (m poolsTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + } } + m.table, cmd = m.table.Update(msg) + return m, cmd } +// View renders the interactive pool table model. +func (m poolsTableModel) View() string { + return "\n" + ui.TitleStyle.Render("Hostodo Capacity") + "\n\n" + m.table.View() + "\n\n" + ui.HelpStyle.Render("↑/↓: navigate • q: quit") +} + +// poolIdentifier chooses the stable public pool identifier for display. func poolIdentifier(pool poolSummary) string { if pool.PoolID != "" { return pool.PoolID @@ -173,6 +309,7 @@ func poolIdentifier(pool poolSummary) string { return valueOrDash(pool.ID) } +// poolPlanName returns the plan name from either flat or nested API shapes. func poolPlanName(pool poolSummary) string { if pool.PlanName != "" { return pool.PlanName @@ -183,9 +320,21 @@ func poolPlanName(pool poolSummary) string { return "-" } +// valueOrDash returns '-' for empty API display fields. func valueOrDash(value string) string { if value == "" { return "-" } return value } + +// truncatePool shortens values to fit pool table columns. +func truncatePool(value string, width int) string { + if len(value) <= width { + return value + } + if width <= 3 { + return value[:width] + } + return value[:width-3] + "..." +}