diff --git a/api/types.go b/api/types.go index de033eb..b12536e 100644 --- a/api/types.go +++ b/api/types.go @@ -1,24 +1,28 @@ package api -import "context" +import ( + "context" + "time" +) type Instance struct { - ID string `json:"-"` - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - IP string `json:"ip"` - CPUCores string `json:"cpuCores"` - Memory string `json:"memory"` - Storage int `json:"storage"` - GPUType string `json:"gpuType"` - NumGPUs string `json:"numGpus"` - Mode string `json:"mode"` - Template string `json:"template"` - CreatedAt string `json:"createdAt"` - Port int `json:"port"` - K8s bool `json:"k8s"` - Promoted bool `json:"promoted"` + ID string `json:"-"` + UUID string `json:"uuid"` + Name string `json:"name"` + Status string `json:"status"` + IP string `json:"ip"` + CPUCores string `json:"cpuCores"` + Memory string `json:"memory"` + Storage int `json:"storage"` + GPUType string `json:"gpuType"` + NumGPUs string `json:"numGpus"` + Mode string `json:"mode"` + Template string `json:"template"` + CreatedAt string `json:"createdAt"` + Port int `json:"port"` + K8s bool `json:"k8s"` + Promoted bool `json:"promoted"` + ProvisioningTime time.Time `json:"provisioningTime,omitempty"` } type ThunderTemplateDefaultSpecs struct { diff --git a/tui/status.go b/tui/status.go index 940806f..8b3d270 100644 --- a/tui/status.go +++ b/tui/status.go @@ -11,31 +11,38 @@ import ( "github.com/Thunder-Compute/thunder-cli/api" "github.com/Thunder-Compute/thunder-cli/utils" + "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( - headerStyle lipgloss.Style - runningStyle lipgloss.Style - startingStyle lipgloss.Style - restoringStyle lipgloss.Style - deletingStyle lipgloss.Style - cellStyle lipgloss.Style - timestampStyle lipgloss.Style + headerStyle lipgloss.Style + runningStyle lipgloss.Style + startingStyle lipgloss.Style + restoringStyle lipgloss.Style + deletingStyle lipgloss.Style + provisioningStyle lipgloss.Style + cellStyle lipgloss.Style + timestampStyle lipgloss.Style +) + +const ( + provisioningExpectedDuration = 10 * time.Minute ) type StatusModel struct { - instances []api.Instance - client *api.Client - monitoring bool - lastUpdate time.Time - quitting bool - spinner spinner.Model - err error - done bool - cancelled bool + instances []api.Instance + client *api.Client + monitoring bool + lastUpdate time.Time + quitting bool + spinner spinner.Model + err error + done bool + cancelled bool + progressBars map[string]progress.Model } type tickMsg time.Time @@ -51,11 +58,12 @@ func NewStatusModel(client *api.Client, monitoring bool, instances []api.Instanc s := NewPrimarySpinner() return StatusModel{ - client: client, - monitoring: monitoring, - instances: instances, - lastUpdate: time.Now(), - spinner: s, + client: client, + monitoring: monitoring, + instances: instances, + lastUpdate: time.Now(), + spinner: s, + progressBars: make(map[string]progress.Model), } } @@ -146,6 +154,12 @@ func (m StatusModel) View() string { b.WriteString(m.renderTable()) b.WriteString("\n") + // Render provisioning progress section + provisioningSection := m.renderProvisioningSection() + if provisioningSection != "" { + b.WriteString(provisioningSection) + } + if m.quitting { timestamp := m.lastUpdate.Format("15:04:05") b.WriteString(timestampStyle.Render(fmt.Sprintf("Last updated: %s", timestamp))) @@ -203,7 +217,7 @@ func (m StatusModel) renderTable() string { colWidths := map[string]int{ "ID": 4, "Name": 14, - "Status": 12, + "Status": 14, "Address": 18, "Mode": 15, "Disk": 8, @@ -282,12 +296,93 @@ func (m StatusModel) formatStatus(status string, width int) string { style = restoringStyle case "DELETING": style = deletingStyle + case "PROVISIONING": + style = provisioningStyle default: style = lipgloss.NewStyle() } return style.Render(truncate(status, width)) } +func (m *StatusModel) ensureProgressBar(gpuType string) { + if _, exists := m.progressBars[gpuType]; !exists { + p := progress.New( + progress.WithScaledGradient("#FFA500", "#FF8C00"), + progress.WithWidth(70), + ) + m.progressBars[gpuType] = p + } +} + +func (m *StatusModel) renderProvisioningSection() string { + // Group instances with PROVISIONING status by GPU type + instancesByGPU := make(map[string][]api.Instance) + for _, instance := range m.instances { + if instance.Status == "PROVISIONING" { + instancesByGPU[instance.GPUType] = append(instancesByGPU[instance.GPUType], instance) + } + } + + if len(instancesByGPU) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("\n") + b.WriteString(primaryStyle.Bold(true).Render("Provisioning Instances:")) + b.WriteString("\n\n") + + for gpuType, instances := range instancesByGPU { + m.ensureProgressBar(gpuType) + progressBar := m.progressBars[gpuType] + + // Use the earliest provisioning time from all instances of this GPU type + earliestTime := instances[0].ProvisioningTime + for _, instance := range instances[1:] { + if instance.ProvisioningTime.Before(earliestTime) { + earliestTime = instance.ProvisioningTime + } + } + + // Calculate progress using the GetProgress method + progressPercent := utils.GetProgress(earliestTime, provisioningExpectedDuration) + + // Calculate time remaining + elapsed := time.Since(earliestTime) + remaining := provisioningExpectedDuration - elapsed + if remaining < 0 { + remaining = 0 + } + remainingMinutes := int(remaining.Minutes()) + if remainingMinutes < 1 { + remainingMinutes = 1 + } + + // Build comma-separated list of instance names + var names []string + for _, instance := range instances { + names = append(names, instance.Name) + } + instanceList := strings.Join(names, ", ") + + // Render instance names (grey, unbolded) + b.WriteString(fmt.Sprintf(" %s\n", SubtleTextStyle().Render(instanceList))) + + // Render progress bar + b.WriteString(fmt.Sprintf(" %s\n", progressBar.ViewAs(progressPercent))) + + // Render message (compressed) + message := fmt.Sprintf(" ~%d min total, ~%d min remaining", + int(provisioningExpectedDuration.Minutes()), + remainingMinutes, + ) + b.WriteString(timestampStyle.Render(message)) + b.WriteString("\n\n") + } + + return b.String() +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -314,6 +409,8 @@ func RunStatus(client *api.Client, monitoring bool, instances []api.Instance) er deletingStyle = ErrorStyle() + provisioningStyle = WarningStyle() + cellStyle = lipgloss.NewStyle(). Padding(0, 1) diff --git a/utils/timing.go b/utils/timing.go new file mode 100644 index 0000000..933a711 --- /dev/null +++ b/utils/timing.go @@ -0,0 +1,31 @@ +package utils + +import ( + "math" + "time" +) + +/* +* +GetProgress to track state of a progress bar + + @param startTime (time.Time) - the time at which an event started + @param expectedDuration (time.Duration) - how long the event is expected to last + @return (float64) - the proportion of status bar completion +*/ +func GetProgress(startTime time.Time, expectedDuration time.Duration) float64 { + elapsed := time.Since(startTime) + if elapsed <= 0 { + return 0 + } + if expectedDuration <= 0 { + return 1 + } + if elapsed <= expectedDuration { + return 0.8 * (float64(elapsed) / float64(expectedDuration)) + } + + over := elapsed - expectedDuration + ratio := float64(over) / float64(expectedDuration) + return 1 - 0.2*math.Exp(-ratio) +}