Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions api/types.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
141 changes: 119 additions & 22 deletions tui/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions utils/timing.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading