From 68aca234f20a30289585c45a7f21bfb1ace74ca5 Mon Sep 17 00:00:00 2001 From: Brian Model Date: Tue, 13 Jan 2026 17:11:20 -0800 Subject: [PATCH 1/7] add instance modify handler --- api/client.go | 49 +++ api/types.go | 17 + cmd/modify.go | 412 ++++++++++++++++++++++++ tui/help-menus/modify.go | 140 ++++++++ tui/modify.go | 676 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1294 insertions(+) create mode 100644 cmd/modify.go create mode 100644 tui/help-menus/modify.go create mode 100644 tui/modify.go diff --git a/api/client.go b/api/client.go index 0a95326..1426b89 100644 --- a/api/client.go +++ b/api/client.go @@ -395,6 +395,55 @@ func (c *Client) DeleteInstance(instanceID string) (*DeleteInstanceResponse, err }, nil } +// ModifyInstance modifies an existing instance configuration +func (c *Client) ModifyInstance(instanceID string, req InstanceModifyRequest) (*InstanceModifyResponse, error) { + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v1/instances/%s/modify", c.baseURL, instanceID) + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + switch resp.StatusCode { + case 401: + return nil, fmt.Errorf("authentication failed: invalid token") + case 404: + return nil, fmt.Errorf("instance not found") + case 400: + return nil, fmt.Errorf("invalid request: %s", string(body)) + case 409: + return nil, fmt.Errorf("instance cannot be modified (may not be in RUNNING state)") + case 200, 201, 202: + // Success - continue to parse + default: + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var modifyResp InstanceModifyResponse + if err := json.Unmarshal(body, &modifyResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &modifyResp, nil +} + // AddSSHKey generates and adds SSH keypair to instance func (c *Client) AddSSHKey(instanceID string) (*AddSSHKeyResponse, error) { return c.AddSSHKeyCtx(context.Background(), instanceID) diff --git a/api/types.go b/api/types.go index 14a52f9..daeac8f 100644 --- a/api/types.go +++ b/api/types.go @@ -62,6 +62,23 @@ type DeleteInstanceResponse struct { Success bool `json:"success"` } +type InstanceModifyRequest struct { + CpuCores *int `json:"cpu_cores,omitempty"` + GpuType *string `json:"gpu_type,omitempty"` + NumGpus *int `json:"num_gpus,omitempty"` + DiskSizeGb *int `json:"disk_size_gb,omitempty"` + Mode *string `json:"mode,omitempty"` +} + +type InstanceModifyResponse struct { + Identifier string `json:"identifier"` + InstanceName string `json:"instance_name"` + Mode *string `json:"mode,omitempty"` + GpuType *string `json:"gpu_type,omitempty"` + NumGpus *int `json:"num_gpus,omitempty"` + Message string `json:"message,omitempty"` +} + type AddSSHKeyResponse struct { UUID string `json:"uuid"` Key string `json:"key"` diff --git a/cmd/modify.go b/cmd/modify.go new file mode 100644 index 0000000..a7c213c --- /dev/null +++ b/cmd/modify.go @@ -0,0 +1,412 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/Thunder-Compute/thunder-cli/api" + "github.com/Thunder-Compute/thunder-cli/tui" + helpmenus "github.com/Thunder-Compute/thunder-cli/tui/help-menus" + "github.com/Thunder-Compute/thunder-cli/tui/theme" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +// modifyCmd represents the modify command +var modifyCmd = &cobra.Command{ + Use: "modify [instance_id]", + Short: "Modify a Thunder Compute instance configuration", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runModify(cmd, args); err != nil { + PrintError(err) + os.Exit(1) + } + }, +} + +func init() { + modifyCmd.Flags().String("mode", "", "Instance mode (prototyping or production)") + modifyCmd.Flags().String("gpu", "", "GPU type (t4, a100, h100)") + modifyCmd.Flags().Int("num-gpus", 0, "Number of GPUs (production mode: 1, 2, or 4)") + modifyCmd.Flags().Int("vcpus", 0, "CPU cores (prototyping mode: 4, 8, 16, or 32)") + modifyCmd.Flags().Int("disk-size-gb", 0, "Disk size in GB (100-1000, cannot shrink)") + + modifyCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + helpmenus.RenderModifyHelp(cmd) + }) + + rootCmd.AddCommand(modifyCmd) +} + +func runModify(cmd *cobra.Command, args []string) error { + config, err := LoadConfig() + if err != nil { + return fmt.Errorf("not authenticated. Please run 'tnr login' first") + } + + if config.Token == "" { + return fmt.Errorf("no authentication token found. Please run 'tnr login'") + } + + client := api.NewClient(config.Token, config.APIURL) + + instanceID := args[0] + + // Fetch instances to convert index/ID to instance object + busy := tui.NewBusyModel("Fetching instances...") + bp := tea.NewProgram(busy, tea.WithOutput(os.Stdout)) + busyDone := make(chan struct{}) + go func() { + _, _ = bp.Run() + close(busyDone) + }() + + instances, err := client.ListInstances() + bp.Send(tui.BusyDoneMsg{}) + <-busyDone + + if err != nil { + return fmt.Errorf("failed to fetch instances: %w", err) + } + + // Find instance by ID or UUID + var selectedInstance *api.Instance + for i := range instances { + if instances[i].ID == instanceID || instances[i].UUID == instanceID { + selectedInstance = &instances[i] + break + } + } + + if selectedInstance == nil { + return fmt.Errorf("instance '%s' not found", instanceID) + } + + // Validate instance is RUNNING + if selectedInstance.Status != "RUNNING" { + return fmt.Errorf("instance must be in RUNNING state to modify (current state: %s)", selectedInstance.Status) + } + + // Check if interactive mode (no flags set) or flag mode + isInteractive := !cmd.Flags().Changed("mode") && + !cmd.Flags().Changed("gpu") && + !cmd.Flags().Changed("num-gpus") && + !cmd.Flags().Changed("vcpus") && + !cmd.Flags().Changed("disk-size-gb") + + var modifyConfig *tui.ModifyConfig + var modifyReq api.InstanceModifyRequest + + if isInteractive { + // Run interactive mode + modifyConfig, err = tui.RunModifyInteractive(client, selectedInstance) + if err != nil { + if _, ok := err.(*tui.CancellationError); ok { + PrintWarningSimple("User cancelled modification process") + return nil + } + if err.Error() == "no changes" { + PrintWarningSimple("No changes were requested. Instance configuration unchanged.") + return nil + } + return err + } + + // Build request from interactive config + modifyReq, err = buildModifyRequestFromConfig(modifyConfig, selectedInstance) + if err != nil { + return err + } + } else { + // Flag mode - validate flags and build request + modifyReq, err = buildModifyRequestFromFlags(cmd, selectedInstance) + if err != nil { + return err + } + } + + // Make API call with progress spinner + p := tea.NewProgram(newModifyProgressModel(client, selectedInstance.UUID, modifyReq)) + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("error during modification: %w", err) + } + + progressModel := finalModel.(modifyProgressModel) + + if progressModel.cancelled { + PrintWarningSimple("User cancelled modification") + return nil + } + + if progressModel.err != nil { + return fmt.Errorf("failed to modify instance: %w", progressModel.err) + } + + // Display success message + PrintSuccessSimple(fmt.Sprintf("✓ Instance modified successfully!")) + fmt.Println() + fmt.Printf("Instance ID: %s\n", progressModel.resp.Identifier) + fmt.Printf("Instance Name: %s\n", progressModel.resp.InstanceName) + + if progressModel.resp.Mode != nil { + fmt.Printf("New Mode: %s\n", *progressModel.resp.Mode) + } + if progressModel.resp.GpuType != nil { + fmt.Printf("New GPU: %s\n", *progressModel.resp.GpuType) + } + if progressModel.resp.NumGpus != nil { + fmt.Printf("New GPUs: %d\n", *progressModel.resp.NumGpus) + } + + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" • Instance is restarting with new configuration") + fmt.Println(" • Run 'tnr status' to monitor progress") + fmt.Printf(" • Run 'tnr connect %s' once RUNNING\n", selectedInstance.ID) + + return nil +} + +func buildModifyRequestFromConfig(config *tui.ModifyConfig, currentInstance *api.Instance) (api.InstanceModifyRequest, error) { + req := api.InstanceModifyRequest{} + + if config.ModeChanged { + req.Mode = &config.Mode + } + + if config.GPUChanged { + req.GpuType = &config.GPUType + } + + if config.ComputeChanged { + effectiveMode := currentInstance.Mode + if config.ModeChanged { + effectiveMode = config.Mode + } + + if effectiveMode == "prototyping" { + req.CpuCores = &config.VCPUs + } else { + req.NumGpus = &config.NumGPUs + } + } + + if config.DiskChanged { + req.DiskSizeGb = &config.DiskSizeGB + } + + // Check if any changes were made + if !config.ModeChanged && !config.GPUChanged && !config.ComputeChanged && !config.DiskChanged { + return req, fmt.Errorf("no changes specified") + } + + return req, nil +} + +func buildModifyRequestFromFlags(cmd *cobra.Command, currentInstance *api.Instance) (api.InstanceModifyRequest, error) { + req := api.InstanceModifyRequest{} + hasChanges := false + + // Helper function to check if value is in slice + contains := func(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false + } + + // Mode validation + if cmd.Flags().Changed("mode") { + mode, _ := cmd.Flags().GetString("mode") + mode = strings.ToLower(mode) + if mode != "prototyping" && mode != "production" { + return req, fmt.Errorf("mode must be 'prototyping' or 'production'") + } + + // If switching modes, validate dependent fields + if mode != currentInstance.Mode { + if mode == "production" && !cmd.Flags().Changed("num-gpus") { + return req, fmt.Errorf("switching to production requires --num-gpus flag (1, 2, or 4)") + } + if mode == "prototyping" && !cmd.Flags().Changed("vcpus") { + return req, fmt.Errorf("switching to prototyping requires --vcpus flag (4, 8, 16, or 32)") + } + } + req.Mode = &mode + hasChanges = true + } + + // Determine effective mode for GPU and compute validation + effectiveMode := currentInstance.Mode + if req.Mode != nil { + effectiveMode = *req.Mode + } + + // GPU type validation + if cmd.Flags().Changed("gpu") { + gpuType, _ := cmd.Flags().GetString("gpu") + gpuType = strings.ToLower(gpuType) + + // Normalize GPU names + gpuMap := map[string]string{ + "t4": "t4", + "a100": "a100xl", + "h100": "h100", + } + + normalizedGPU, ok := gpuMap[gpuType] + if !ok { + return req, fmt.Errorf("invalid GPU type '%s'. Valid options: t4, a100, h100", gpuType) + } + + // Validate GPU compatibility with mode + if effectiveMode == "prototyping" && normalizedGPU != "t4" && normalizedGPU != "a100xl" { + return req, fmt.Errorf("GPU type '%s' is not available in prototyping mode (use t4 or a100)", gpuType) + } + if effectiveMode == "production" && normalizedGPU == "t4" { + return req, fmt.Errorf("GPU type 't4' is not available in production mode (use a100 or h100)") + } + + req.GpuType = &normalizedGPU + hasChanges = true + } + + // VCPUs validation (prototyping only) + if cmd.Flags().Changed("vcpus") { + vcpus, _ := cmd.Flags().GetInt("vcpus") + validVCPUs := []int{4, 8, 16, 32} + if !contains(validVCPUs, vcpus) { + return req, fmt.Errorf("vcpus must be 4, 8, 16, or 32") + } + + // Check mode compatibility + if effectiveMode == "production" { + return req, fmt.Errorf("production mode does not use --vcpus flag. Use --num-gpus instead (vCPUs auto-calculated)") + } + + req.CpuCores = &vcpus + hasChanges = true + } + + // NumGPUs validation (production only) + if cmd.Flags().Changed("num-gpus") { + numGPUs, _ := cmd.Flags().GetInt("num-gpus") + validGPUs := []int{1, 2, 4} + if !contains(validGPUs, numGPUs) { + return req, fmt.Errorf("num-gpus must be 1, 2, or 4") + } + + // Check mode compatibility + if effectiveMode == "prototyping" { + return req, fmt.Errorf("prototyping mode does not use --num-gpus flag (always 1 GPU). Use --vcpus instead") + } + + req.NumGpus = &numGPUs + hasChanges = true + } + + // Disk size validation + if cmd.Flags().Changed("disk-size-gb") { + diskSize, _ := cmd.Flags().GetInt("disk-size-gb") + if diskSize < currentInstance.Storage { + return req, fmt.Errorf("disk size cannot be smaller than current size (%d GB)", currentInstance.Storage) + } + if diskSize > 1000 { + return req, fmt.Errorf("disk size must be between %d and 1000 GB", currentInstance.Storage) + } + req.DiskSizeGb = &diskSize + hasChanges = true + } + + if !hasChanges { + return req, fmt.Errorf("no changes specified. Use flags to modify instance configuration") + } + + return req, nil +} + +// Progress model for modify operation +type modifyProgressModel struct { + client *api.Client + uuid string + req api.InstanceModifyRequest + spinner spinner.Model + message string + done bool + err error + resp *api.InstanceModifyResponse + cancelled bool +} + +func newModifyProgressModel(client *api.Client, uuid string, req api.InstanceModifyRequest) modifyProgressModel { + theme.Init(os.Stdout) + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = theme.Primary() + + return modifyProgressModel{ + client: client, + uuid: uuid, + req: req, + spinner: s, + message: "Modifying instance...", + } +} + +type modifyInstanceResultMsg struct { + resp *api.InstanceModifyResponse + err error +} + +func (m modifyProgressModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + modifyInstanceCmd(m.client, m.uuid, m.req), + ) +} + +func modifyInstanceCmd(client *api.Client, uuid string, req api.InstanceModifyRequest) tea.Cmd { + return func() tea.Msg { + resp, err := client.ModifyInstance(uuid, req) + return modifyInstanceResultMsg{ + resp: resp, + err: err, + } + } +} + +func (m modifyProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.cancelled = true + return m, tea.Quit + } + + case modifyInstanceResultMsg: + m.done = true + m.err = msg.err + m.resp = msg.resp + return m, tea.Quit + + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m modifyProgressModel) View() string { + if m.done || m.cancelled { + return "" + } + return fmt.Sprintf("\n %s %s\n\n", m.spinner.View(), m.message) +} diff --git a/tui/help-menus/modify.go b/tui/help-menus/modify.go new file mode 100644 index 0000000..146296b --- /dev/null +++ b/tui/help-menus/modify.go @@ -0,0 +1,140 @@ +package helpmenus + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +func RenderModifyHelp(cmd *cobra.Command) { + InitHelpStyles(os.Stdout) + + var output strings.Builder + + header := ` +╭─────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ MODIFY COMMAND │ +│ Modify Thunder Compute instance configuration │ +│ │ +╰─────────────────────────────────────────────────────────────────────────────╯ + ` + + output.WriteString(HeaderStyle.Render(header)) + + // Usage Section + output.WriteString(SectionStyle.Render("● USAGE")) + output.WriteString("\n\n") + output.WriteString(" ") + output.WriteString(CommandStyle.Render("Interactive")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("tnr modify [instance_id]")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(CommandStyle.Render("Prototyping")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("tnr modify [instance_id] --mode prototyping --gpu {t4|a100} --vcpus {4|8|16|32} [--disk-size-gb {100-1000}]")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(CommandStyle.Render("Production")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("tnr modify [instance_id] --mode production --gpu {a100|h100} --num-gpus {1|2|4} [--disk-size-gb {100-1000}]")) + output.WriteString("\n\n") + + // Examples Section + output.WriteString(SectionStyle.Render("● EXAMPLES")) + output.WriteString("\n\n") + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Interactive mode with step-by-step wizard")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify 0")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Modify disk size only")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify 0 --disk-size-gb 500")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Switch to prototyping mode with 16 vCPUs")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify 0 --mode prototyping --vcpus 16 --gpu t4")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Switch to production mode with 2 GPUs")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify 0 --mode production --num-gpus 2 --gpu a100")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Upgrade GPU and increase disk")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify 0 --gpu h100 --disk-size-gb 800")) + output.WriteString("\n\n") + + // Flags Section + output.WriteString(SectionStyle.Render("● FLAGS")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(FlagStyle.Render("--mode")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("Instance mode: prototyping or production")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(FlagStyle.Render("--gpu")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("GPU type (prototyping: t4 or a100, production: a100 or h100)")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(FlagStyle.Render("--num-gpus")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("Number of GPUs (production only): 1, 2, or 4")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(FlagStyle.Render("--vcpus")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("CPU cores (prototyping only): 4, 8, 16, or 32 (8GB RAM per vCPU)")) + output.WriteString("\n") + + output.WriteString(" ") + output.WriteString(FlagStyle.Render("--disk-size-gb")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("Disk storage in GB: 100-1000 (cannot be smaller than current size)")) + output.WriteString("\n\n") + + // Important Notes Section + output.WriteString(SectionStyle.Render("● IMPORTANT NOTES")) + output.WriteString("\n\n") + output.WriteString(" ") + output.WriteString(DescStyle.Render("• Instance must be in RUNNING state to modify")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(DescStyle.Render("• Modifying an instance will restart it (brief downtime)")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(DescStyle.Render("• Disk size cannot be reduced (only increased)")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(DescStyle.Render("• When switching modes, you must specify compute values (--vcpus or --num-gpus)")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(DescStyle.Render("• T4 GPUs are only available in prototyping mode")) + output.WriteString("\n\n") + + fmt.Fprint(os.Stdout, output.String()) +} diff --git a/tui/modify.go b/tui/modify.go new file mode 100644 index 0000000..cb328a4 --- /dev/null +++ b/tui/modify.go @@ -0,0 +1,676 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/Thunder-Compute/thunder-cli/api" + "github.com/Thunder-Compute/thunder-cli/tui/theme" +) + +type modifyStep int + +const ( + modifyStepMode modifyStep = iota + modifyStepGPU + modifyStepCompute + modifyStepDiskSize + modifyStepConfirmation + modifyStepComplete +) + +// ModifyConfig holds the configuration for modifying an instance +type ModifyConfig struct { + Mode string + GPUType string + NumGPUs int + VCPUs int + DiskSizeGB int + Confirmed bool + ModeChanged bool + GPUChanged bool + ComputeChanged bool + DiskChanged bool +} + +type modifyModel struct { + step modifyStep + cursor int + config ModifyConfig + currentInstance *api.Instance + client *api.Client + diskInput textinput.Model + diskInputTouched bool + err error + validationErr error + quitting bool + cancelled bool + + styles modifyStyles +} + +type modifyStyles struct { + title lipgloss.Style + selected lipgloss.Style + cursor lipgloss.Style + panel lipgloss.Style + label lipgloss.Style + help lipgloss.Style +} + +func newModifyStyles() modifyStyles { + panelBase := PrimaryStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.PrimaryColor)). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + return modifyStyles{ + title: PrimaryTitleStyle().MarginBottom(1), + selected: PrimarySelectedStyle(), + cursor: PrimaryCursorStyle(), + panel: panelBase, + label: LabelStyle(), + help: HelpStyle(), + } +} + +func NewModifyModel(client *api.Client, instance *api.Instance) modifyModel { + styles := newModifyStyles() + + ti := textinput.New() + ti.Placeholder = fmt.Sprintf("%d", instance.Storage) + ti.SetValue(fmt.Sprintf("%d", instance.Storage)) + ti.CharLimit = 4 + ti.Width = 20 + ti.Prompt = "▶ " + + m := modifyModel{ + step: modifyStepMode, + cursor: 0, + config: ModifyConfig{}, + currentInstance: instance, + client: client, + diskInput: ti, + diskInputTouched: false, + styles: styles, + } + + // Set initial cursor to current mode position (case-insensitive) + if strings.EqualFold(instance.Mode, "prototyping") { + m.cursor = 0 + } else { + m.cursor = 1 + } + + return m +} + +func (m modifyModel) Init() tea.Cmd { + return nil +} + +func (m modifyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case "q": + if m.step == modifyStepConfirmation { + // Q at confirmation should select cancel option + break + } + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case "esc": + if m.step > modifyStepMode { + m.step-- + m.cursor = 0 + m.validationErr = nil + if m.step == modifyStepDiskSize { + m.diskInput.Focus() + // Reset the touched flag when going back to disk size step + m.diskInputTouched = false + } else { + m.diskInput.Blur() + } + return m, nil + } + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case "up": + if m.step != modifyStepDiskSize { + if m.cursor > 0 { + m.cursor-- + } + } + + case "down": + if m.step != modifyStepDiskSize { + maxCursor := m.getMaxCursor() + if m.cursor < maxCursor { + m.cursor++ + } + } + + case "enter": + return m.handleEnter() + } + + // Handle text input for disk size step + if m.step == modifyStepDiskSize { + // Check if this is a character input (not a control key) + if len(msg.String()) == 1 && msg.Type == tea.KeyRunes { + // If this is the first character typed, clear the input first + if !m.diskInputTouched { + m.diskInput.SetValue("") + m.diskInputTouched = true + } + } + var cmd tea.Cmd + m.diskInput, cmd = m.diskInput.Update(msg) + return m, cmd + } + } + + return m, nil +} + +func (m modifyModel) handleEnter() (tea.Model, tea.Cmd) { + m.validationErr = nil + + switch m.step { + case modifyStepMode: + modeOptions := []string{"prototyping", "production"} + newMode := modeOptions[m.cursor] + m.config.Mode = newMode + // Case-insensitive comparison + m.config.ModeChanged = !strings.EqualFold(newMode, m.currentInstance.Mode) + m.step = modifyStepGPU + // Set cursor to current GPU position for next step + m.cursor = m.getCurrentGPUCursorPosition() + return m, nil + + case modifyStepGPU: + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + var gpuValues []string + if effectiveMode == "prototyping" { + gpuValues = []string{"t4", "a100xl"} + } else { + gpuValues = []string{"a100xl", "h100"} + } + + m.config.GPUType = gpuValues[m.cursor] + // Case-insensitive comparison + m.config.GPUChanged = !strings.EqualFold(m.config.GPUType, m.currentInstance.GPUType) + m.step = modifyStepCompute + // Set cursor to current compute position for next step + m.cursor = m.getCurrentComputeCursorPosition() + return m, nil + + case modifyStepCompute: + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + if effectiveMode == "prototyping" { + vcpuOptions := []int{4, 8, 16, 32} + m.config.VCPUs = vcpuOptions[m.cursor] + currentVCPUs, _ := strconv.Atoi(m.currentInstance.CPUCores) + m.config.ComputeChanged = (m.config.VCPUs != currentVCPUs) + } else { // production + gpuOptions := []int{1, 2, 4} + m.config.NumGPUs = gpuOptions[m.cursor] + currentNumGPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) + m.config.ComputeChanged = (m.config.NumGPUs != currentNumGPUs) + } + m.step = modifyStepDiskSize + m.cursor = 0 + m.diskInputTouched = false + m.diskInput.Focus() + return m, nil + + case modifyStepDiskSize: + diskSize, err := strconv.Atoi(m.diskInput.Value()) + if err != nil || diskSize < 100 || diskSize > 1000 { + m.validationErr = fmt.Errorf("disk size must be between 100 and 1000 GB") + return m, nil + } + + // Check against current instance size + if diskSize < m.currentInstance.Storage { + m.validationErr = fmt.Errorf("disk size cannot be smaller than current size (%d GB)", m.currentInstance.Storage) + return m, nil + } + + m.config.DiskSizeGB = diskSize + m.config.DiskChanged = (diskSize != m.currentInstance.Storage) + m.validationErr = nil + + // Check if any changes were made + if !m.config.ModeChanged && !m.config.GPUChanged && !m.config.ComputeChanged && !m.config.DiskChanged { + // No changes, exit with a special error + m.err = fmt.Errorf("no changes") + m.quitting = true + return m, tea.Quit + } + + m.step = modifyStepConfirmation + m.cursor = 0 + m.diskInput.Blur() + + case modifyStepConfirmation: + if m.cursor == 0 { // Apply Changes + m.config.Confirmed = true + m.step = modifyStepComplete + m.quitting = true + return m, tea.Quit + } else { // Cancel + m.cancelled = true + m.quitting = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m modifyModel) getCurrentGPUCursorPosition() int { + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + currentGPU := strings.ToLower(m.currentInstance.GPUType) + + if effectiveMode == "prototyping" { + if currentGPU == "t4" { + return 0 + } + return 1 // a100xl + } else { + if currentGPU == "a100xl" || currentGPU == "a100" { + return 0 + } + return 1 // h100 + } +} + +func (m modifyModel) formatGPUType(gpuType string) string { + gpuType = strings.ToLower(gpuType) + switch gpuType { + case "t4": + return "T4" + case "a100xl", "a100": + return "A100 80GB" + case "h100": + return "H100" + default: + return gpuType + } +} + +func (m modifyModel) getCurrentComputeCursorPosition() int { + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + if effectiveMode == "prototyping" { + currentVCPUs, _ := strconv.Atoi(m.currentInstance.CPUCores) + vcpuOptions := []int{4, 8, 16, 32} + for i, vcpus := range vcpuOptions { + if vcpus == currentVCPUs { + return i + } + } + return 0 + } else { + currentNumGPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) + gpuOptions := []int{1, 2, 4} + for i, gpus := range gpuOptions { + if gpus == currentNumGPUs { + return i + } + } + return 0 + } +} + +func (m modifyModel) getMaxCursor() int { + switch m.step { + case modifyStepMode: + return 1 // Prototyping, Production + + case modifyStepGPU: + return 1 // 2 GPU options (t4/a100xl or a100xl/h100) + + case modifyStepCompute: + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + if effectiveMode == "prototyping" { + return 3 // 4 vCPU options + } else { + return 2 // 3 GPU options + } + + case modifyStepConfirmation: + return 1 // Apply Changes, Cancel + } + + return 0 +} + +func (m modifyModel) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + + // Title + s.WriteString(m.styles.title.Render("⚙ Modify Instance Configuration")) + s.WriteString("\n\n") + + // Show current instance info + s.WriteString(m.styles.label.Render(fmt.Sprintf("Instance: (%s) %s", m.currentInstance.ID, m.currentInstance.Name))) + s.WriteString("\n\n") + + // Render current step + switch m.step { + case modifyStepMode: + s.WriteString(m.renderModeStep()) + case modifyStepGPU: + s.WriteString(m.renderGPUStep()) + case modifyStepCompute: + s.WriteString(m.renderComputeStep()) + case modifyStepDiskSize: + s.WriteString(m.renderDiskSizeStep()) + case modifyStepConfirmation: + s.WriteString(m.renderConfirmationStep()) + } + + // Help text + s.WriteString("\n") + if m.step == modifyStepConfirmation { + s.WriteString(m.styles.help.Render("↑/↓: Navigate Enter: Confirm Q: Cancel")) + } else if m.step == modifyStepDiskSize { + s.WriteString(m.styles.help.Render("Type disk size Enter: Continue ESC: Back Q: Quit")) + } else { + s.WriteString(m.styles.help.Render("↑/↓: Navigate Enter: Select ESC: Back Q: Quit")) + } + + return s.String() +} + +func (m modifyModel) renderModeStep() string { + var s strings.Builder + + s.WriteString("Select instance mode:\n\n") + + modeLabels := []string{ + "Prototyping (lowest cost, dev/test)", + "Production (highest stability, long-running)", + } + modeValues := []string{"prototyping", "production"} + + for i, label := range modeLabels { + option := label + if strings.EqualFold(modeValues[i], m.currentInstance.Mode) { + option += " [current]" + } + + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + option = m.styles.selected.Render(option) + } + s.WriteString(fmt.Sprintf("%s%s\n", cursor, option)) + } + + return s.String() +} + +func (m modifyModel) renderGPUStep() string { + var s strings.Builder + + s.WriteString("Select GPU type:\n\n") + + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + var optionLabels []string + var optionValues []string + + if effectiveMode == "prototyping" { + optionLabels = []string{ + "T4 (more affordable)", + "A100 80GB (more powerful)", + } + optionValues = []string{"t4", "a100xl"} + } else { + optionLabels = []string{ + "A100 80GB", + "H100", + } + optionValues = []string{"a100xl", "h100"} + } + + for i, label := range optionLabels { + option := label + // Case-insensitive comparison for [current] marker + if strings.EqualFold(optionValues[i], m.currentInstance.GPUType) { + option += " [current]" + } + + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + option = m.styles.selected.Render(option) + } + s.WriteString(fmt.Sprintf("%s%s\n", cursor, option)) + } + + return s.String() +} + +func (m modifyModel) renderComputeStep() string { + var s strings.Builder + + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + if effectiveMode == "prototyping" { + s.WriteString("Select vCPU count (8GB RAM per vCPU):\n\n") + + currentVCPUs, _ := strconv.Atoi(m.currentInstance.CPUCores) + vcpuOptions := []int{4, 8, 16, 32} + for i, vcpus := range vcpuOptions { + ram := vcpus * 8 + option := fmt.Sprintf("%d vCPUs (%d GB RAM)", vcpus, ram) + + if vcpus == currentVCPUs { + option += " [current]" + } + + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + option = m.styles.selected.Render(option) + } + s.WriteString(fmt.Sprintf("%s%s\n", cursor, option)) + } + } else { // production + s.WriteString("Select number of GPUs (18 vCPUs per GPU, 144GB RAM per GPU):\n\n") + + currentNumGPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) + gpuOptions := []int{1, 2, 4} + for i, gpus := range gpuOptions { + vcpus := gpus * 18 + ram := gpus * 144 + option := fmt.Sprintf("%d GPU(s) → %d vCPUs, %d GB RAM", gpus, vcpus, ram) + + if gpus == currentNumGPUs { + option += " [current]" + } + + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + option = m.styles.selected.Render(option) + } + s.WriteString(fmt.Sprintf("%s%s\n", cursor, option)) + } + } + + return s.String() +} + +func (m modifyModel) renderDiskSizeStep() string { + var s strings.Builder + + s.WriteString(fmt.Sprintf("Enter disk size (GB) [current: %d GB]:\n\n", m.currentInstance.Storage)) + s.WriteString(fmt.Sprintf("Range: %d-1000 GB (cannot be smaller than current)\n\n", m.currentInstance.Storage)) + s.WriteString(m.diskInput.View()) + s.WriteString("\n\n") + + if m.validationErr != nil { + s.WriteString(errorStyleTUI.Render(fmt.Sprintf("✗ Error: %v", m.validationErr))) + s.WriteString("\n") + } + + return s.String() +} + +func (m modifyModel) renderConfirmationStep() string { + var s strings.Builder + + s.WriteString("Review your configuration changes:\n\n") + + // Build change summary + var changes []string + + if m.config.ModeChanged { + changes = append(changes, fmt.Sprintf("Mode: %s → %s", m.currentInstance.Mode, m.config.Mode)) + } + + if m.config.GPUChanged { + currentGPU := m.formatGPUType(m.currentInstance.GPUType) + newGPU := m.formatGPUType(m.config.GPUType) + changes = append(changes, fmt.Sprintf("GPU Type: %s → %s", currentGPU, newGPU)) + } + + if m.config.ComputeChanged { + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + + if effectiveMode == "prototyping" { + currentRAM, _ := strconv.Atoi(m.currentInstance.CPUCores) + currentRAM *= 8 + newRAM := m.config.VCPUs * 8 + changes = append(changes, fmt.Sprintf("vCPUs: %s → %d", m.currentInstance.CPUCores, m.config.VCPUs)) + changes = append(changes, fmt.Sprintf("RAM: %d GB → %d GB", currentRAM, newRAM)) + } else { + currentVCPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) + currentVCPUs *= 18 + newVCPUs := m.config.NumGPUs * 18 + currentRAM, _ := strconv.Atoi(m.currentInstance.NumGPUs) + currentRAM *= 144 + newRAM := m.config.NumGPUs * 144 + changes = append(changes, fmt.Sprintf("GPUs: %s → %d", m.currentInstance.NumGPUs, m.config.NumGPUs)) + changes = append(changes, fmt.Sprintf("vCPUs: %d → %d", currentVCPUs, newVCPUs)) + changes = append(changes, fmt.Sprintf("RAM: %d GB → %d GB", currentRAM, newRAM)) + } + } + + if m.config.DiskChanged { + changes = append(changes, fmt.Sprintf("Disk Size: %d GB → %d GB", m.currentInstance.Storage, m.config.DiskSizeGB)) + } + + if len(changes) == 0 { + s.WriteString(warningStyleTUI.Render("⚠ Warning: No changes detected")) + s.WriteString("\n\n") + } else { + // Display changes in a box + changeBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.PrimaryColor)). + Padding(1, 2) + + changeText := "CHANGES:\n" + for _, change := range changes { + changeText += change + "\n" + } + + s.WriteString(changeBox.Render(changeText)) + s.WriteString("\n\n") + } + + s.WriteString(warningStyleTUI.Render("⚠ Warning: Modifying will restart the instance, running processes will be interrupted.")) + s.WriteString("\n\n") + + s.WriteString("Confirm modification?\n\n") + + options := []string{"✓ Apply Changes", "✗ Cancel"} + for i, option := range options { + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + option = m.styles.selected.Render(option) + } + s.WriteString(fmt.Sprintf("%s%s\n", cursor, option)) + } + + return s.String() +} + +// RunModifyInteractive starts the interactive modify flow +func RunModifyInteractive(client *api.Client, instance *api.Instance) (*ModifyConfig, error) { + m := NewModifyModel(client, instance) + p := tea.NewProgram(m) + + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running interactive modify: %w", err) + } + + finalModifyModel := finalModel.(modifyModel) + + if finalModifyModel.cancelled { + return nil, &CancellationError{} + } + + if finalModifyModel.err != nil { + return nil, finalModifyModel.err + } + + return &finalModifyModel.config, nil +} From c65aef725df80007a4a7bf641bf4ca0a77662f7a Mon Sep 17 00:00:00 2001 From: Brian Model Date: Tue, 13 Jan 2026 17:15:54 -0800 Subject: [PATCH 2/7] tidy --- cmd/modify.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/modify.go b/cmd/modify.go index a7c213c..ab9a566 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -255,9 +255,9 @@ func buildModifyRequestFromFlags(cmd *cobra.Command, currentInstance *api.Instan // Normalize GPU names gpuMap := map[string]string{ - "t4": "t4", - "a100": "a100xl", - "h100": "h100", + "t4": "t4", + "a100": "a100xl", + "h100": "h100", } normalizedGPU, ok := gpuMap[gpuType] From 4f058589d9a59be5eb51254cfc62f00b97ade97f Mon Sep 17 00:00:00 2001 From: Brian Model Date: Tue, 13 Jan 2026 17:31:24 -0800 Subject: [PATCH 3/7] Tidy and add h100s to modify --- cmd/modify.go | 2 +- tui/modify.go | 65 +++++++++++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/cmd/modify.go b/cmd/modify.go index ab9a566..b5db8e3 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -147,7 +147,7 @@ func runModify(cmd *cobra.Command, args []string) error { } // Display success message - PrintSuccessSimple(fmt.Sprintf("✓ Instance modified successfully!")) + PrintSuccessSimple("✓ Instance modified successfully!") fmt.Println() fmt.Printf("Instance ID: %s\n", progressModel.resp.Identifier) fmt.Printf("Instance Name: %s\n", progressModel.resp.InstanceName) diff --git a/tui/modify.go b/tui/modify.go index cb328a4..10eeb4b 100644 --- a/tui/modify.go +++ b/tui/modify.go @@ -81,7 +81,7 @@ func newModifyStyles() modifyStyles { } } -func NewModifyModel(client *api.Client, instance *api.Instance) modifyModel { +func NewModifyModel(client *api.Client, instance *api.Instance) tea.Model { styles := newModifyStyles() ti := textinput.New() @@ -213,7 +213,7 @@ func (m modifyModel) handleEnter() (tea.Model, tea.Cmd) { var gpuValues []string if effectiveMode == "prototyping" { - gpuValues = []string{"t4", "a100xl"} + gpuValues = []string{"t4", "a100xl", "h100"} } else { gpuValues = []string{"a100xl", "h100"} } @@ -284,11 +284,11 @@ func (m modifyModel) handleEnter() (tea.Model, tea.Cmd) { m.step = modifyStepComplete m.quitting = true return m, tea.Quit - } else { // Cancel - m.cancelled = true - m.quitting = true - return m, tea.Quit } + // Cancel + m.cancelled = true + m.quitting = true + return m, tea.Quit } return m, nil @@ -306,13 +306,15 @@ func (m modifyModel) getCurrentGPUCursorPosition() int { if currentGPU == "t4" { return 0 } - return 1 // a100xl - } else { - if currentGPU == "a100xl" || currentGPU == "a100" { - return 0 + if currentGPU == "a100xl" { + return 1 } - return 1 // h100 + return 2 // h100 + } + if currentGPU == "a100xl" { + return 0 } + return 1 // h100 } func (m modifyModel) formatGPUType(gpuType string) string { @@ -320,7 +322,7 @@ func (m modifyModel) formatGPUType(gpuType string) string { switch gpuType { case "t4": return "T4" - case "a100xl", "a100": + case "a100xl": return "A100 80GB" case "h100": return "H100" @@ -344,16 +346,15 @@ func (m modifyModel) getCurrentComputeCursorPosition() int { } } return 0 - } else { - currentNumGPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) - gpuOptions := []int{1, 2, 4} - for i, gpus := range gpuOptions { - if gpus == currentNumGPUs { - return i - } + } + currentNumGPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) + gpuOptions := []int{1, 2, 4} + for i, gpus := range gpuOptions { + if gpus == currentNumGPUs { + return i } - return 0 } + return 0 } func (m modifyModel) getMaxCursor() int { @@ -362,7 +363,14 @@ func (m modifyModel) getMaxCursor() int { return 1 // Prototyping, Production case modifyStepGPU: - return 1 // 2 GPU options (t4/a100xl or a100xl/h100) + effectiveMode := m.currentInstance.Mode + if m.config.ModeChanged { + effectiveMode = m.config.Mode + } + if effectiveMode == "prototyping" { + return 2 // 3 GPU options (t4/a100xl/h100) + } + return 1 // 2 GPU options (a100xl/h100) case modifyStepCompute: effectiveMode := m.currentInstance.Mode @@ -372,9 +380,8 @@ func (m modifyModel) getMaxCursor() int { if effectiveMode == "prototyping" { return 3 // 4 vCPU options - } else { - return 2 // 3 GPU options } + return 2 // 3 GPU options case modifyStepConfirmation: return 1 // Apply Changes, Cancel @@ -414,11 +421,12 @@ func (m modifyModel) View() string { // Help text s.WriteString("\n") - if m.step == modifyStepConfirmation { + switch m.step { + case modifyStepConfirmation: s.WriteString(m.styles.help.Render("↑/↓: Navigate Enter: Confirm Q: Cancel")) - } else if m.step == modifyStepDiskSize { + case modifyStepDiskSize: s.WriteString(m.styles.help.Render("Type disk size Enter: Continue ESC: Back Q: Quit")) - } else { + default: s.WriteString(m.styles.help.Render("↑/↓: Navigate Enter: Select ESC: Back Q: Quit")) } @@ -469,9 +477,10 @@ func (m modifyModel) renderGPUStep() string { if effectiveMode == "prototyping" { optionLabels = []string{ "T4 (more affordable)", - "A100 80GB (more powerful)", + "A100 80GB (high performance)", + "H100 (most powerful)", } - optionValues = []string{"t4", "a100xl"} + optionValues = []string{"t4", "a100xl", "h100"} } else { optionLabels = []string{ "A100 80GB", From b30480ba9ea2474b81346ca01dd1e768afcd6549 Mon Sep 17 00:00:00 2001 From: Brian Model Date: Tue, 13 Jan 2026 19:17:11 -0800 Subject: [PATCH 4/7] Fix modify --- api/client.go | 15 ++++- cmd/modify.go | 83 +++++++++++++++++------- tui/help-menus/modify.go | 29 +++++++-- tui/modify.go | 134 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 229 insertions(+), 32 deletions(-) diff --git a/api/client.go b/api/client.go index 1426b89..511320c 100644 --- a/api/client.go +++ b/api/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "sort" "time" ) @@ -105,6 +106,11 @@ func (c *Client) ListInstancesWithIPUpdateCtx(ctx context.Context) ([]Instance, instances = append(instances, instance) } + // Sort instances by ID for consistent ordering + sort.Slice(instances, func(i, j int) bool { + return instances[i].ID < instances[j].ID + }) + return instances, nil } @@ -275,6 +281,11 @@ func (c *Client) ListInstances() ([]Instance, error) { instances = append(instances, instance) } + // Sort instances by ID for consistent ordering + sort.Slice(instances, func(i, j int) bool { + return instances[i].ID < instances[j].ID + }) + return instances, nil } @@ -396,13 +407,13 @@ func (c *Client) DeleteInstance(instanceID string) (*DeleteInstanceResponse, err } // ModifyInstance modifies an existing instance configuration -func (c *Client) ModifyInstance(instanceID string, req InstanceModifyRequest) (*InstanceModifyResponse, error) { +func (c *Client) ModifyInstance(instanceIndex int, req InstanceModifyRequest) (*InstanceModifyResponse, error) { jsonData, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf("%s/v1/instances/%s/modify", c.baseURL, instanceID) + url := fmt.Sprintf("%s/v1/instances/%d/modify", c.baseURL, instanceIndex) httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/cmd/modify.go b/cmd/modify.go index b5db8e3..17c404f 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "strings" "github.com/Thunder-Compute/thunder-cli/api" @@ -16,9 +17,9 @@ import ( // modifyCmd represents the modify command var modifyCmd = &cobra.Command{ - Use: "modify [instance_id]", + Use: "modify [instance_index_or_id]", Short: "Modify a Thunder Compute instance configuration", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { if err := runModify(cmd, args); err != nil { PrintError(err) @@ -53,9 +54,7 @@ func runModify(cmd *cobra.Command, args []string) error { client := api.NewClient(config.Token, config.APIURL) - instanceID := args[0] - - // Fetch instances to convert index/ID to instance object + // Fetch instances busy := tui.NewBusyModel("Fetching instances...") bp := tea.NewProgram(busy, tea.WithOutput(os.Stdout)) busyDone := make(chan struct{}) @@ -72,17 +71,57 @@ func runModify(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to fetch instances: %w", err) } - // Find instance by ID or UUID + if len(instances) == 0 { + PrintWarningSimple("No instances found. Use 'tnr create' to create a Thunder Compute instance.") + return nil + } + var selectedInstance *api.Instance - for i := range instances { - if instances[i].ID == instanceID || instances[i].UUID == instanceID { - selectedInstance = &instances[i] - break + var selectedIndex int + + // Determine which instance to modify + if len(args) == 0 { + // No argument - show interactive selector + selectedInstance, err = tui.RunModifyInstanceSelector(client, instances) + if err != nil { + if _, ok := err.(*tui.CancellationError); ok { + PrintWarningSimple("User cancelled modification process") + return nil + } + return err } - } + // Find the index of the selected instance + for i := range instances { + if instances[i].ID == selectedInstance.ID { + selectedIndex = i + break + } + } + } else { + instanceIdentifier := args[0] + + // Try to parse as index first + if index, err := strconv.Atoi(instanceIdentifier); err == nil { + // It's a number - treat as index + if index < 0 || index >= len(instances) { + return fmt.Errorf("invalid instance index %d. Valid range: 0-%d", index, len(instances)-1) + } + selectedInstance = &instances[index] + selectedIndex = index + } else { + // Not a number - treat as ID or UUID + for i := range instances { + if instances[i].ID == instanceIdentifier || instances[i].UUID == instanceIdentifier { + selectedInstance = &instances[i] + selectedIndex = i + break + } + } - if selectedInstance == nil { - return fmt.Errorf("instance '%s' not found", instanceID) + if selectedInstance == nil { + return fmt.Errorf("instance '%s' not found", instanceIdentifier) + } + } } // Validate instance is RUNNING @@ -129,7 +168,7 @@ func runModify(cmd *cobra.Command, args []string) error { } // Make API call with progress spinner - p := tea.NewProgram(newModifyProgressModel(client, selectedInstance.UUID, modifyReq)) + p := tea.NewProgram(newModifyProgressModel(client, selectedIndex, modifyReq)) finalModel, err := p.Run() if err != nil { return fmt.Errorf("error during modification: %w", err) @@ -266,8 +305,8 @@ func buildModifyRequestFromFlags(cmd *cobra.Command, currentInstance *api.Instan } // Validate GPU compatibility with mode - if effectiveMode == "prototyping" && normalizedGPU != "t4" && normalizedGPU != "a100xl" { - return req, fmt.Errorf("GPU type '%s' is not available in prototyping mode (use t4 or a100)", gpuType) + if effectiveMode == "prototyping" && normalizedGPU != "t4" && normalizedGPU != "a100xl" && normalizedGPU != "h100" { + return req, fmt.Errorf("GPU type '%s' is not available in prototyping mode (use t4, a100, or h100)", gpuType) } if effectiveMode == "production" && normalizedGPU == "t4" { return req, fmt.Errorf("GPU type 't4' is not available in production mode (use a100 or h100)") @@ -334,7 +373,7 @@ func buildModifyRequestFromFlags(cmd *cobra.Command, currentInstance *api.Instan // Progress model for modify operation type modifyProgressModel struct { client *api.Client - uuid string + index int req api.InstanceModifyRequest spinner spinner.Model message string @@ -344,7 +383,7 @@ type modifyProgressModel struct { cancelled bool } -func newModifyProgressModel(client *api.Client, uuid string, req api.InstanceModifyRequest) modifyProgressModel { +func newModifyProgressModel(client *api.Client, index int, req api.InstanceModifyRequest) modifyProgressModel { theme.Init(os.Stdout) s := spinner.New() s.Spinner = spinner.Dot @@ -352,7 +391,7 @@ func newModifyProgressModel(client *api.Client, uuid string, req api.InstanceMod return modifyProgressModel{ client: client, - uuid: uuid, + index: index, req: req, spinner: s, message: "Modifying instance...", @@ -367,13 +406,13 @@ type modifyInstanceResultMsg struct { func (m modifyProgressModel) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, - modifyInstanceCmd(m.client, m.uuid, m.req), + modifyInstanceCmd(m.client, m.index, m.req), ) } -func modifyInstanceCmd(client *api.Client, uuid string, req api.InstanceModifyRequest) tea.Cmd { +func modifyInstanceCmd(client *api.Client, index int, req api.InstanceModifyRequest) tea.Cmd { return func() tea.Msg { - resp, err := client.ModifyInstance(uuid, req) + resp, err := client.ModifyInstance(index, req) return modifyInstanceResultMsg{ resp: resp, err: err, diff --git a/tui/help-menus/modify.go b/tui/help-menus/modify.go index 146296b..bf28038 100644 --- a/tui/help-menus/modify.go +++ b/tui/help-menus/modify.go @@ -30,26 +30,38 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString(" ") output.WriteString(CommandStyle.Render("Interactive")) output.WriteString(" ") - output.WriteString(DescStyle.Render("tnr modify [instance_id]")) + output.WriteString(DescStyle.Render("tnr modify # Select instance from list")) output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandStyle.Render(" ")) + output.WriteString(" ") + output.WriteString(DescStyle.Render("tnr modify [index|id] # Use instance index (0,1,2...) or ID")) + output.WriteString("\n\n") output.WriteString(" ") output.WriteString(CommandStyle.Render("Prototyping")) output.WriteString(" ") - output.WriteString(DescStyle.Render("tnr modify [instance_id] --mode prototyping --gpu {t4|a100} --vcpus {4|8|16|32} [--disk-size-gb {100-1000}]")) + output.WriteString(DescStyle.Render("tnr modify [index|id] --mode prototyping --gpu {t4|a100|h100} --vcpus {4|8|16|32} [--disk-size-gb {100-1000}]")) output.WriteString("\n") output.WriteString(" ") output.WriteString(CommandStyle.Render("Production")) output.WriteString(" ") - output.WriteString(DescStyle.Render("tnr modify [instance_id] --mode production --gpu {a100|h100} --num-gpus {1|2|4} [--disk-size-gb {100-1000}]")) + output.WriteString(DescStyle.Render("tnr modify [index|id] --mode production --gpu {a100|h100} --num-gpus {1|2|4} [--disk-size-gb {100-1000}]")) output.WriteString("\n\n") // Examples Section output.WriteString(SectionStyle.Render("● EXAMPLES")) output.WriteString("\n\n") output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Interactive mode with step-by-step wizard")) + output.WriteString(ExampleStyle.Render("# Interactive instance selector and step-by-step wizard")) + output.WriteString("\n") + output.WriteString(" ") + output.WriteString(CommandTextStyle.Render("tnr modify")) + output.WriteString("\n\n") + + output.WriteString(" ") + output.WriteString(ExampleStyle.Render("# Interactive mode using instance index")) output.WriteString("\n") output.WriteString(" ") output.WriteString(CommandTextStyle.Render("tnr modify 0")) @@ -77,10 +89,10 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString("\n\n") output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Upgrade GPU and increase disk")) + output.WriteString(ExampleStyle.Render("# Upgrade GPU and increase disk (can use instance ID)")) output.WriteString("\n") output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify 0 --gpu h100 --disk-size-gb 800")) + output.WriteString(CommandTextStyle.Render("tnr modify abc123 --gpu h100 --disk-size-gb 800")) output.WriteString("\n\n") // Flags Section @@ -96,7 +108,7 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString(" ") output.WriteString(FlagStyle.Render("--gpu")) output.WriteString(" ") - output.WriteString(DescStyle.Render("GPU type (prototyping: t4 or a100, production: a100 or h100)")) + output.WriteString(DescStyle.Render("GPU type (prototyping: t4, a100, or h100; production: a100 or h100)")) output.WriteString("\n") output.WriteString(" ") @@ -121,6 +133,9 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString(SectionStyle.Render("● IMPORTANT NOTES")) output.WriteString("\n\n") output.WriteString(" ") + output.WriteString(DescStyle.Render("• Instance can be selected by index (0, 1, 2...) or by ID")) + output.WriteString("\n") + output.WriteString(" ") output.WriteString(DescStyle.Render("• Instance must be in RUNNING state to modify")) output.WriteString("\n") output.WriteString(" ") diff --git a/tui/modify.go b/tui/modify.go index 10eeb4b..ee67a5f 100644 --- a/tui/modify.go +++ b/tui/modify.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "strconv" "strings" @@ -11,6 +12,7 @@ import ( "github.com/Thunder-Compute/thunder-cli/api" "github.com/Thunder-Compute/thunder-cli/tui/theme" + "github.com/Thunder-Compute/thunder-cli/utils" ) type modifyStep int @@ -398,7 +400,7 @@ func (m modifyModel) View() string { var s strings.Builder // Title - s.WriteString(m.styles.title.Render("⚙ Modify Instance Configuration")) + s.WriteString(m.styles.title.Render("Modify Instance Configuration")) s.WriteString("\n\n") // Show current instance info @@ -683,3 +685,133 @@ func RunModifyInteractive(client *api.Client, instance *api.Instance) (*ModifyCo return &finalModifyModel.config, nil } + +// RunModifyInstanceSelector shows an interactive instance selector for modify +func RunModifyInstanceSelector(client *api.Client, instances []api.Instance) (*api.Instance, error) { + InitCommonStyles(os.Stdout) + m := newModifyInstanceSelectorModel(instances) + p := tea.NewProgram(m) + + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running instance selector: %w", err) + } + + result := finalModel.(modifyInstanceSelectorModel) + + if result.cancelled { + return nil, &CancellationError{} + } + + if result.selected == nil { + return nil, &CancellationError{} + } + + return result.selected, nil +} + +type modifyInstanceSelectorModel struct { + cursor int + instances []api.Instance + selected *api.Instance + cancelled bool + quitting bool + styles modifyStyles +} + +func newModifyInstanceSelectorModel(instances []api.Instance) modifyInstanceSelectorModel { + return modifyInstanceSelectorModel{ + cursor: 0, + instances: instances, + styles: newModifyStyles(), + } +} + +func (m modifyInstanceSelectorModel) Init() tea.Cmd { + return nil +} + +func (m modifyInstanceSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case "up": + if m.cursor > 0 { + m.cursor-- + } + + case "down": + if m.cursor < len(m.instances)-1 { + m.cursor++ + } + + case "enter": + m.selected = &m.instances[m.cursor] + m.quitting = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m modifyInstanceSelectorModel) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + + s.WriteString(m.styles.title.Render("⚙ Modify Thunder Compute Instance")) + s.WriteString("\n") + s.WriteString("Select an instance to modify:\n\n") + + for i, instance := range m.instances { + cursor := " " + if m.cursor == i { + cursor = m.styles.cursor.Render("▶ ") + } + + // Determine status style + var statusStyle lipgloss.Style + statusSuffix := "" + switch instance.Status { + case "RUNNING": + statusStyle = SuccessStyle() + case "STARTING": + statusStyle = WarningStyle() + case "DELETING": + statusStyle = ErrorStyle() + statusSuffix = " (deleting)" + default: + statusStyle = lipgloss.NewStyle() + } + + idAndName := fmt.Sprintf("(%s) %s", instance.ID, instance.Name) + if m.cursor == i { + idAndName = m.styles.selected.Render(idAndName) + } + + statusText := statusStyle.Render(fmt.Sprintf("(%s)", instance.Status)) + rest := fmt.Sprintf(" %s%s - %s - %sx%s - %s", + statusText, + statusSuffix, + instance.IP, + instance.NumGPUs, + instance.GPUType, + utils.Capitalize(instance.Mode), + ) + + s.WriteString(fmt.Sprintf("%s%s%s\n", cursor, idAndName, rest)) + } + + s.WriteString("\n") + s.WriteString(m.styles.help.Render("↑/↓: Navigate Enter: Select Q: Cancel\n")) + + return s.String() +} From 9a12be0b355f73cd488c7497c9171f928f702a29 Mon Sep 17 00:00:00 2001 From: Brian Model Date: Wed, 14 Jan 2026 12:52:31 -0800 Subject: [PATCH 5/7] fix how modify sets index --- api/client.go | 4 +-- cmd/modify.go | 76 ++++++++++++++++++++++----------------------------- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/api/client.go b/api/client.go index 511320c..b4403bf 100644 --- a/api/client.go +++ b/api/client.go @@ -407,13 +407,13 @@ func (c *Client) DeleteInstance(instanceID string) (*DeleteInstanceResponse, err } // ModifyInstance modifies an existing instance configuration -func (c *Client) ModifyInstance(instanceIndex int, req InstanceModifyRequest) (*InstanceModifyResponse, error) { +func (c *Client) ModifyInstance(instanceID string, req InstanceModifyRequest) (*InstanceModifyResponse, error) { jsonData, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf("%s/v1/instances/%d/modify", c.baseURL, instanceIndex) + url := fmt.Sprintf("%s/v1/instances/%s/modify", c.baseURL, instanceID) httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/cmd/modify.go b/cmd/modify.go index 17c404f..134b449 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -77,7 +77,6 @@ func runModify(cmd *cobra.Command, args []string) error { } var selectedInstance *api.Instance - var selectedIndex int // Determine which instance to modify if len(args) == 0 { @@ -90,37 +89,28 @@ func runModify(cmd *cobra.Command, args []string) error { } return err } - // Find the index of the selected instance + } else { + instanceIdentifier := args[0] + + // First try to find by ID or UUID for i := range instances { - if instances[i].ID == selectedInstance.ID { - selectedIndex = i + if instances[i].ID == instanceIdentifier || instances[i].UUID == instanceIdentifier { + selectedInstance = &instances[i] break } } - } else { - instanceIdentifier := args[0] - // Try to parse as index first - if index, err := strconv.Atoi(instanceIdentifier); err == nil { - // It's a number - treat as index - if index < 0 || index >= len(instances) { - return fmt.Errorf("invalid instance index %d. Valid range: 0-%d", index, len(instances)-1) - } - selectedInstance = &instances[index] - selectedIndex = index - } else { - // Not a number - treat as ID or UUID - for i := range instances { - if instances[i].ID == instanceIdentifier || instances[i].UUID == instanceIdentifier { - selectedInstance = &instances[i] - selectedIndex = i - break + // If not found and it's a number, try as array index (for backwards compatibility) + if selectedInstance == nil { + if index, err := strconv.Atoi(instanceIdentifier); err == nil { + if index >= 0 && index < len(instances) { + selectedInstance = &instances[index] } } + } - if selectedInstance == nil { - return fmt.Errorf("instance '%s' not found", instanceIdentifier) - } + if selectedInstance == nil { + return fmt.Errorf("instance '%s' not found", instanceIdentifier) } } @@ -168,7 +158,7 @@ func runModify(cmd *cobra.Command, args []string) error { } // Make API call with progress spinner - p := tea.NewProgram(newModifyProgressModel(client, selectedIndex, modifyReq)) + p := tea.NewProgram(newModifyProgressModel(client, selectedInstance.ID, modifyReq)) finalModel, err := p.Run() if err != nil { return fmt.Errorf("error during modification: %w", err) @@ -372,29 +362,29 @@ func buildModifyRequestFromFlags(cmd *cobra.Command, currentInstance *api.Instan // Progress model for modify operation type modifyProgressModel struct { - client *api.Client - index int - req api.InstanceModifyRequest - spinner spinner.Model - message string - done bool - err error - resp *api.InstanceModifyResponse - cancelled bool + client *api.Client + instanceID string + req api.InstanceModifyRequest + spinner spinner.Model + message string + done bool + err error + resp *api.InstanceModifyResponse + cancelled bool } -func newModifyProgressModel(client *api.Client, index int, req api.InstanceModifyRequest) modifyProgressModel { +func newModifyProgressModel(client *api.Client, instanceID string, req api.InstanceModifyRequest) modifyProgressModel { theme.Init(os.Stdout) s := spinner.New() s.Spinner = spinner.Dot s.Style = theme.Primary() return modifyProgressModel{ - client: client, - index: index, - req: req, - spinner: s, - message: "Modifying instance...", + client: client, + instanceID: instanceID, + req: req, + spinner: s, + message: "Modifying instance...", } } @@ -406,13 +396,13 @@ type modifyInstanceResultMsg struct { func (m modifyProgressModel) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, - modifyInstanceCmd(m.client, m.index, m.req), + modifyInstanceCmd(m.client, m.instanceID, m.req), ) } -func modifyInstanceCmd(client *api.Client, index int, req api.InstanceModifyRequest) tea.Cmd { +func modifyInstanceCmd(client *api.Client, instanceID string, req api.InstanceModifyRequest) tea.Cmd { return func() tea.Msg { - resp, err := client.ModifyInstance(index, req) + resp, err := client.ModifyInstance(instanceID, req) return modifyInstanceResultMsg{ resp: resp, err: err, From d98504888519e02063b72393e28c584d3cc45789 Mon Sep 17 00:00:00 2001 From: Brian Model Date: Wed, 14 Jan 2026 15:24:22 -0800 Subject: [PATCH 6/7] Don't update config from the cli --- utils/thunder.go | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/utils/thunder.go b/utils/thunder.go index 35f98fe..008a0d3 100644 --- a/utils/thunder.go +++ b/utils/thunder.go @@ -88,13 +88,6 @@ func CleanupLdSoPreloadIfBinaryMissing(client *SSHClient) error { } func ConfigureThunderVirtualization(client *SSHClient, instanceID, deviceID, gpuType string, gpuCount int, token, binaryHash string, existingConfig *ThunderConfig) error { - gpuTypeMatches := false - gpuCountMatches := false - if existingConfig != nil { - gpuTypeMatches = strings.EqualFold(existingConfig.GPUType, gpuType) - gpuCountMatches = existingConfig.GPUCount == gpuCount - } - expectedHash := NormalizeHash(binaryHash) isValidHash := expectedHash != "" && len(expectedHash) == 32 && IsHexString(expectedHash) hashAlgorithm := DetectHashAlgorithm(expectedHash) @@ -105,24 +98,13 @@ func ConfigureThunderVirtualization(client *SSHClient, instanceID, deviceID, gpu } } - if gpuTypeMatches && gpuCountMatches && existingConfig != nil && existingConfig.DeviceID != "" && isValidHash && existingHash != "" && existingHash == expectedHash { + // If binary hash matches, no update needed + if isValidHash && existingHash != "" && existingHash == expectedHash { return nil } - configNeedsUpdate := existingConfig == nil || existingConfig.DeviceID == "" || !gpuTypeMatches || !gpuCountMatches binaryNeedsUpdate := !isValidHash || existingHash == "" || existingHash != expectedHash - config := ThunderConfig{ - InstanceID: instanceID, - DeviceID: deviceID, - GPUType: gpuType, - GPUCount: gpuCount, - } - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - configB64 := base64.StdEncoding.EncodeToString(configJSON) tokenB64 := base64.StdEncoding.EncodeToString([]byte(token)) var scriptParts []string @@ -131,18 +113,10 @@ func ConfigureThunderVirtualization(client *SSHClient, instanceID, deviceID, gpu if binaryNeedsUpdate { scriptParts = append(scriptParts, fmt.Sprintf("curl -sL %s -o /tmp/libthunder.tmp && mv /tmp/libthunder.tmp %s", thunderBinaryURL, thunderLibPath)) - } - - if binaryNeedsUpdate || configNeedsUpdate { scriptParts = append(scriptParts, fmt.Sprintf("sudo ln -sf %s %s", thunderLibPath, thunderSymlink)) scriptParts = append(scriptParts, fmt.Sprintf("echo '%s' | sudo tee %s > /dev/null", thunderSymlink, ldPreloadPath)) } - if configNeedsUpdate { - scriptParts = append(scriptParts, fmt.Sprintf("echo '%s' | base64 -d > %s", configB64, thunderConfigPath)) - scriptParts = append(scriptParts, fmt.Sprintf("sudo ln -sf %s /etc/thunder/config.json", thunderConfigPath)) - } - // Always ensure token is set (in case it changed) scriptParts = append(scriptParts, fmt.Sprintf("echo '%s' | base64 -d > %s", tokenB64, tokenPath)) scriptParts = append(scriptParts, fmt.Sprintf("sudo ln -sf %s %s", tokenPath, tokenSymlink)) From d7b39e73f72d322d5d9a916fd6d7b30fb7be10fd Mon Sep 17 00:00:00 2001 From: cpeterson42 Date: Sun, 18 Jan 2026 15:34:10 -0800 Subject: [PATCH 7/7] formatting --- cmd/modify.go | 70 ++++++++++++++++++++++++++-------------- tui/delete.go | 4 +-- tui/help-menus/modify.go | 62 +++++------------------------------ tui/modify.go | 48 +++++++++++---------------- tui/snapshot_create.go | 3 +- 5 files changed, 75 insertions(+), 112 deletions(-) diff --git a/cmd/modify.go b/cmd/modify.go index 134b449..d40bcd6 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -12,6 +12,7 @@ import ( "github.com/Thunder-Compute/thunder-cli/tui/theme" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -175,28 +176,7 @@ func runModify(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to modify instance: %w", progressModel.err) } - // Display success message - PrintSuccessSimple("✓ Instance modified successfully!") - fmt.Println() - fmt.Printf("Instance ID: %s\n", progressModel.resp.Identifier) - fmt.Printf("Instance Name: %s\n", progressModel.resp.InstanceName) - - if progressModel.resp.Mode != nil { - fmt.Printf("New Mode: %s\n", *progressModel.resp.Mode) - } - if progressModel.resp.GpuType != nil { - fmt.Printf("New GPU: %s\n", *progressModel.resp.GpuType) - } - if progressModel.resp.NumGpus != nil { - fmt.Printf("New GPUs: %d\n", *progressModel.resp.NumGpus) - } - - fmt.Println() - fmt.Println("Next steps:") - fmt.Println(" • Instance is restarting with new configuration") - fmt.Println(" • Run 'tnr status' to monitor progress") - fmt.Printf(" • Run 'tnr connect %s' once RUNNING\n", selectedInstance.ID) - + // Success output is rendered in the View() method return nil } @@ -434,8 +414,50 @@ func (m modifyProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m modifyProgressModel) View() string { - if m.done || m.cancelled { - return "" + if m.done { + if m.cancelled { + return "" + } + + if m.err != nil { + return "" + } + + headerStyle := theme.Primary().Bold(true) + labelStyle := theme.Neutral() + valueStyle := lipgloss.NewStyle().Bold(true) + cmdStyle := theme.Neutral() + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.PrimaryColor)). + Padding(1, 2) + + var lines []string + successTitleStyle := theme.Success() + lines = append(lines, successTitleStyle.Render("✓ Instance modified successfully!")) + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Instance ID:")+" "+valueStyle.Render(m.resp.Identifier)) + lines = append(lines, labelStyle.Render("Instance Name:")+" "+valueStyle.Render(m.resp.InstanceName)) + + if m.resp.Mode != nil { + lines = append(lines, labelStyle.Render("New Mode:")+" "+valueStyle.Render(*m.resp.Mode)) + } + if m.resp.GpuType != nil { + lines = append(lines, labelStyle.Render("New GPU:")+" "+valueStyle.Render(*m.resp.GpuType)) + } + if m.resp.NumGpus != nil { + lines = append(lines, labelStyle.Render("New GPUs:")+" "+valueStyle.Render(fmt.Sprintf("%d", *m.resp.NumGpus))) + } + + lines = append(lines, "") + lines = append(lines, headerStyle.Render("Next steps:")) + lines = append(lines, cmdStyle.Render(" • Instance is restarting with new configuration")) + lines = append(lines, cmdStyle.Render(" • Run 'tnr status' to monitor progress")) + lines = append(lines, cmdStyle.Render(fmt.Sprintf(" • Run 'tnr connect %s' once RUNNING", m.instanceID))) + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + return "\n" + boxStyle.Render(content) + "\n\n" } + return fmt.Sprintf("\n %s %s\n\n", m.spinner.View(), m.message) } diff --git a/tui/delete.go b/tui/delete.go index 5b9f1c1..8c3c449 100644 --- a/tui/delete.go +++ b/tui/delete.go @@ -212,10 +212,9 @@ func (m deleteModel) View() string { } statusText := statusStyle.Render(fmt.Sprintf("(%s)", instance.Status)) - rest := fmt.Sprintf(" %s%s - %s - %sx%s - %s", + rest := fmt.Sprintf(" %s%s - %sx%s - %s", statusText, statusSuffix, - instance.IP, instance.NumGPUs, instance.GPUType, utils.Capitalize(instance.Mode), @@ -240,7 +239,6 @@ func (m deleteModel) View() string { instanceInfo.WriteString(m.styles.label.Render("ID: ") + m.selected.ID + "\n") instanceInfo.WriteString(m.styles.label.Render("Name: ") + m.selected.Name + "\n") instanceInfo.WriteString(m.styles.label.Render("Status: ") + m.selected.Status + "\n") - instanceInfo.WriteString(m.styles.label.Render("IP Address: ") + m.selected.IP + "\n") instanceInfo.WriteString(m.styles.label.Render("Mode: ") + utils.Capitalize(m.selected.Mode) + "\n") instanceInfo.WriteString(m.styles.label.Render("GPU: ") + m.selected.NumGPUs + "x" + m.selected.GPUType + "\n") instanceInfo.WriteString(m.styles.label.Render("Template: ") + utils.Capitalize(m.selected.Template)) diff --git a/tui/help-menus/modify.go b/tui/help-menus/modify.go index bf28038..24ac42d 100644 --- a/tui/help-menus/modify.go +++ b/tui/help-menus/modify.go @@ -38,61 +38,21 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString(DescStyle.Render("tnr modify [index|id] # Use instance index (0,1,2...) or ID")) output.WriteString("\n\n") - output.WriteString(" ") - output.WriteString(CommandStyle.Render("Prototyping")) - output.WriteString(" ") - output.WriteString(DescStyle.Render("tnr modify [index|id] --mode prototyping --gpu {t4|a100|h100} --vcpus {4|8|16|32} [--disk-size-gb {100-1000}]")) - output.WriteString("\n") - - output.WriteString(" ") - output.WriteString(CommandStyle.Render("Production")) - output.WriteString(" ") - output.WriteString(DescStyle.Render("tnr modify [index|id] --mode production --gpu {a100|h100} --num-gpus {1|2|4} [--disk-size-gb {100-1000}]")) - output.WriteString("\n\n") - // Examples Section output.WriteString(SectionStyle.Render("● EXAMPLES")) output.WriteString("\n\n") output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Interactive instance selector and step-by-step wizard")) + output.WriteString(ExampleStyle.Render("# Interactive mode - select instance and configure step-by-step")) output.WriteString("\n") output.WriteString(" ") output.WriteString(CommandTextStyle.Render("tnr modify")) output.WriteString("\n\n") output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Interactive mode using instance index")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify 0")) - output.WriteString("\n\n") - - output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Modify disk size only")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify 0 --disk-size-gb 500")) - output.WriteString("\n\n") - - output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Switch to prototyping mode with 16 vCPUs")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify 0 --mode prototyping --vcpus 16 --gpu t4")) - output.WriteString("\n\n") - - output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Switch to production mode with 2 GPUs")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify 0 --mode production --num-gpus 2 --gpu a100")) - output.WriteString("\n\n") - - output.WriteString(" ") - output.WriteString(ExampleStyle.Render("# Upgrade GPU and increase disk (can use instance ID)")) + output.WriteString(ExampleStyle.Render("# Modify instance directly with flags")) output.WriteString("\n") output.WriteString(" ") - output.WriteString(CommandTextStyle.Render("tnr modify abc123 --gpu h100 --disk-size-gb 800")) + output.WriteString(CommandTextStyle.Render("tnr modify 0 --gpu h100 --disk-size-gb 500")) output.WriteString("\n\n") // Flags Section @@ -102,31 +62,31 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString(" ") output.WriteString(FlagStyle.Render("--mode")) output.WriteString(" ") - output.WriteString(DescStyle.Render("Instance mode: prototyping or production")) + output.WriteString(DescStyle.Render("Instance mode")) output.WriteString("\n") output.WriteString(" ") output.WriteString(FlagStyle.Render("--gpu")) output.WriteString(" ") - output.WriteString(DescStyle.Render("GPU type (prototyping: t4, a100, or h100; production: a100 or h100)")) + output.WriteString(DescStyle.Render("GPU type")) output.WriteString("\n") output.WriteString(" ") output.WriteString(FlagStyle.Render("--num-gpus")) output.WriteString(" ") - output.WriteString(DescStyle.Render("Number of GPUs (production only): 1, 2, or 4")) + output.WriteString(DescStyle.Render("Number of GPUs (production only)")) output.WriteString("\n") output.WriteString(" ") output.WriteString(FlagStyle.Render("--vcpus")) output.WriteString(" ") - output.WriteString(DescStyle.Render("CPU cores (prototyping only): 4, 8, 16, or 32 (8GB RAM per vCPU)")) + output.WriteString(DescStyle.Render("CPU cores (prototyping only)")) output.WriteString("\n") output.WriteString(" ") output.WriteString(FlagStyle.Render("--disk-size-gb")) output.WriteString(" ") - output.WriteString(DescStyle.Render("Disk storage in GB: 100-1000 (cannot be smaller than current size)")) + output.WriteString(DescStyle.Render("Disk storage in GB")) output.WriteString("\n\n") // Important Notes Section @@ -143,12 +103,6 @@ func RenderModifyHelp(cmd *cobra.Command) { output.WriteString("\n") output.WriteString(" ") output.WriteString(DescStyle.Render("• Disk size cannot be reduced (only increased)")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(DescStyle.Render("• When switching modes, you must specify compute values (--vcpus or --num-gpus)")) - output.WriteString("\n") - output.WriteString(" ") - output.WriteString(DescStyle.Render("• T4 GPUs are only available in prototyping mode")) output.WriteString("\n\n") fmt.Fprint(os.Stdout, output.String()) diff --git a/tui/modify.go b/tui/modify.go index ee67a5f..7443ac4 100644 --- a/tui/modify.go +++ b/tui/modify.go @@ -401,7 +401,7 @@ func (m modifyModel) View() string { // Title s.WriteString(m.styles.title.Render("Modify Instance Configuration")) - s.WriteString("\n\n") + s.WriteString("\n") // Show current instance info s.WriteString(m.styles.label.Render(fmt.Sprintf("Instance: (%s) %s", m.currentInstance.ID, m.currentInstance.Name))) @@ -582,19 +582,19 @@ func (m modifyModel) renderDiskSizeStep() string { func (m modifyModel) renderConfirmationStep() string { var s strings.Builder - s.WriteString("Review your configuration changes:\n\n") + s.WriteString("Review your configuration changes:\n") - // Build change summary - var changes []string + // Build change summary using panel style like create.go + var panel strings.Builder if m.config.ModeChanged { - changes = append(changes, fmt.Sprintf("Mode: %s → %s", m.currentInstance.Mode, m.config.Mode)) + panel.WriteString(m.styles.label.Render("Mode: ") + fmt.Sprintf("%s → %s", utils.Capitalize(m.currentInstance.Mode), utils.Capitalize(m.config.Mode)) + "\n") } if m.config.GPUChanged { currentGPU := m.formatGPUType(m.currentInstance.GPUType) newGPU := m.formatGPUType(m.config.GPUType) - changes = append(changes, fmt.Sprintf("GPU Type: %s → %s", currentGPU, newGPU)) + panel.WriteString(m.styles.label.Render("GPU Type: ") + fmt.Sprintf("%s → %s", currentGPU, newGPU) + "\n") } if m.config.ComputeChanged { @@ -607,8 +607,8 @@ func (m modifyModel) renderConfirmationStep() string { currentRAM, _ := strconv.Atoi(m.currentInstance.CPUCores) currentRAM *= 8 newRAM := m.config.VCPUs * 8 - changes = append(changes, fmt.Sprintf("vCPUs: %s → %d", m.currentInstance.CPUCores, m.config.VCPUs)) - changes = append(changes, fmt.Sprintf("RAM: %d GB → %d GB", currentRAM, newRAM)) + panel.WriteString(m.styles.label.Render("vCPUs: ") + fmt.Sprintf("%s → %d", m.currentInstance.CPUCores, m.config.VCPUs) + "\n") + panel.WriteString(m.styles.label.Render("RAM: ") + fmt.Sprintf("%d GB → %d GB", currentRAM, newRAM) + "\n") } else { currentVCPUs, _ := strconv.Atoi(m.currentInstance.NumGPUs) currentVCPUs *= 18 @@ -616,37 +616,28 @@ func (m modifyModel) renderConfirmationStep() string { currentRAM, _ := strconv.Atoi(m.currentInstance.NumGPUs) currentRAM *= 144 newRAM := m.config.NumGPUs * 144 - changes = append(changes, fmt.Sprintf("GPUs: %s → %d", m.currentInstance.NumGPUs, m.config.NumGPUs)) - changes = append(changes, fmt.Sprintf("vCPUs: %d → %d", currentVCPUs, newVCPUs)) - changes = append(changes, fmt.Sprintf("RAM: %d GB → %d GB", currentRAM, newRAM)) + panel.WriteString(m.styles.label.Render("GPUs: ") + fmt.Sprintf("%s → %d", m.currentInstance.NumGPUs, m.config.NumGPUs) + "\n") + panel.WriteString(m.styles.label.Render("vCPUs: ") + fmt.Sprintf("%d → %d", currentVCPUs, newVCPUs) + "\n") + panel.WriteString(m.styles.label.Render("RAM: ") + fmt.Sprintf("%d GB → %d GB", currentRAM, newRAM) + "\n") } } if m.config.DiskChanged { - changes = append(changes, fmt.Sprintf("Disk Size: %d GB → %d GB", m.currentInstance.Storage, m.config.DiskSizeGB)) + panel.WriteString(m.styles.label.Render("Disk Size: ") + fmt.Sprintf("%d GB → %d GB", m.currentInstance.Storage, m.config.DiskSizeGB) + "\n") } - if len(changes) == 0 { + panelStr := panel.String() + if panelStr == "" { s.WriteString(warningStyleTUI.Render("⚠ Warning: No changes detected")) s.WriteString("\n\n") } else { - // Display changes in a box - changeBox := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color(theme.PrimaryColor)). - Padding(1, 2) - - changeText := "CHANGES:\n" - for _, change := range changes { - changeText += change + "\n" - } - - s.WriteString(changeBox.Render(changeText)) - s.WriteString("\n\n") + // Trim trailing newline for consistent panel rendering + panelStr = strings.TrimSuffix(panelStr, "\n") + s.WriteString(m.styles.panel.Render(panelStr)) } s.WriteString(warningStyleTUI.Render("⚠ Warning: Modifying will restart the instance, running processes will be interrupted.")) - s.WriteString("\n\n") + s.WriteString("\n") s.WriteString("Confirm modification?\n\n") @@ -798,10 +789,9 @@ func (m modifyInstanceSelectorModel) View() string { } statusText := statusStyle.Render(fmt.Sprintf("(%s)", instance.Status)) - rest := fmt.Sprintf(" %s%s - %s - %sx%s - %s", + rest := fmt.Sprintf(" %s%s - %sx%s - %s", statusText, statusSuffix, - instance.IP, instance.NumGPUs, instance.GPUType, utils.Capitalize(instance.Mode), diff --git a/tui/snapshot_create.go b/tui/snapshot_create.go index 309bc6b..f8621d5 100644 --- a/tui/snapshot_create.go +++ b/tui/snapshot_create.go @@ -318,10 +318,9 @@ func (m snapshotCreateModel) View() string { cursor = m.styles.cursor.Render("▶ ") } - display := fmt.Sprintf("(%s) %s - %s - %sx%s", + display := fmt.Sprintf("(%s) %s - %sx%s", instance.ID, instance.Name, - instance.IP, instance.NumGPUs, instance.GPUType, )