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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ bin/

*.tmp


.worktrees/
19 changes: 17 additions & 2 deletions cmd/ggo/studio/studio.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"sort"
"strings"

"github.com/NexusGPU/gpu-go/cmd/ggo/cmdutil"
Expand Down Expand Up @@ -514,12 +515,16 @@ func (r *envListResult) RenderTUI(out *tui.Output) {

styles := tui.DefaultStyles()
var rows [][]string
offlineModes := make(map[string]struct{})
for _, env := range r.envs {
statusIcon := tui.StatusIcon(string(env.Status))
statusStyled := styles.StatusStyle(string(env.Status)).Render(statusIcon + " " + string(env.Status))

sshInfo := styles.Muted.Render("-")
if env.SSHPort > 0 {
sshInfo := styles.Muted.Render("N/A")
if env.Status == studio.StatusUnknown {
offlineModes[string(env.Mode)] = struct{}{}
}
if env.Status != studio.StatusUnknown && env.Status != studio.StatusDeleted && env.SSHPort > 0 && env.SSHHost != "" {
sshInfo = fmt.Sprintf("%s:%d", env.SSHHost, env.SSHPort)
}

Expand All @@ -538,6 +543,16 @@ func (r *envListResult) RenderTUI(out *tui.Output) {
Rows(rows)

out.Println(table.String())

if len(offlineModes) > 0 {
modes := make([]string, 0, len(offlineModes))
for mode := range offlineModes {
modes = append(modes, mode)
}
sort.Strings(modes)
out.Println()
out.Warning(fmt.Sprintf("Container runtime offline: %s", strings.Join(modes, ", ")))
}
}

func newStartCmd() *cobra.Command {
Expand Down
74 changes: 71 additions & 3 deletions internal/studio/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"

Expand Down Expand Up @@ -193,21 +194,88 @@ func (m *Manager) List(ctx context.Context) ([]*Environment, error) {
m.mu.RLock()
defer m.mu.RUnlock()

var allEnvs []*Environment
state, err := m.loadState()
if err != nil {
state = make(map[string]*Environment)
}

runtimeByMode := make(map[Mode][]*Environment)
runtimeIDs := make(map[Mode]map[string]struct{})
offlineModes := make(map[Mode]struct{})

for _, backend := range m.backends {
mode := backend.Mode()
if !backend.IsAvailable(ctx) {
offlineModes[mode] = struct{}{}
continue
}
envs, err := backend.List(ctx)
if err != nil {
continue // Log but continue with other backends
offlineModes[mode] = struct{}{}
continue
}
runtimeByMode[mode] = envs
ids := make(map[string]struct{}, len(envs))
for _, env := range envs {
ids[env.ID] = struct{}{}
}
runtimeIDs[mode] = ids
}

var allEnvs []*Environment
includedIDs := make(map[string]struct{})
for _, envs := range runtimeByMode {
for _, env := range envs {
allEnvs = append(allEnvs, env)
includedIDs[env.ID] = struct{}{}
}
}

stateEnvs := make([]*Environment, 0, len(state))
for _, env := range state {
stateEnvs = append(stateEnvs, env)
}
sort.Slice(stateEnvs, func(i, j int) bool {
if stateEnvs[i].Name == stateEnvs[j].Name {
return stateEnvs[i].ID < stateEnvs[j].ID
}
return stateEnvs[i].Name < stateEnvs[j].Name
})

for _, env := range stateEnvs {
if _, ok := includedIDs[env.ID]; ok {
continue
}

envCopy := cloneEnvironment(env)
if _, offline := offlineModes[env.Mode]; offline {
envCopy.Status = StatusUnknown
} else if _, ok := runtimeIDs[env.Mode]; ok {
envCopy.Status = StatusDeleted
} else {
envCopy.Status = StatusUnknown
}
allEnvs = append(allEnvs, envs...)
allEnvs = append(allEnvs, envCopy)
}

return allEnvs, nil
}

func cloneEnvironment(env *Environment) *Environment {
if env == nil {
return nil
}
copyEnv := *env
if env.Labels != nil {
labels := make(map[string]string, len(env.Labels))
for k, v := range env.Labels {
labels[k] = v
}
copyEnv.Labels = labels
}
return &copyEnv
}

// Stop stops an environment
func (m *Manager) Stop(ctx context.Context, idOrName string) error {
env, err := m.Get(ctx, idOrName)
Expand Down
98 changes: 98 additions & 0 deletions internal/studio/manager_list_ginkgo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package studio

import (
"context"
"os"

"github.com/NexusGPU/gpu-go/internal/platform"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Manager List", func() {
var (
mgr *Manager
tmpDir string
)

BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "ggo-studio-list-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = os.RemoveAll(tmpDir)
})

mgr = &Manager{
paths: platform.DefaultPaths().WithConfigDir(tmpDir),
backends: make(map[Mode]Backend),
}
})

It("marks state-only envs as deleted when runtime is available", func() {
runtimeEnv := &Environment{
ID: "env-runtime",
Name: "runtime",
Mode: ModeDocker,
Status: StatusRunning,
}
backend := &MockBackend{
mode: ModeDocker,
available: true,
listFunc: func(ctx context.Context) ([]*Environment, error) {
return []*Environment{runtimeEnv}, nil
},
}
mgr.RegisterBackend(backend)

stateEnv := &Environment{
ID: "env-deleted",
Name: "deleted",
Mode: ModeDocker,
Status: StatusStopped,
}
Expect(mgr.saveState(map[string]*Environment{stateEnv.ID: stateEnv})).To(Succeed())

envs, err := mgr.List(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(envs).To(HaveLen(2))

var foundRuntime *Environment
var foundDeleted *Environment
for _, env := range envs {
switch env.ID {
case runtimeEnv.ID:
foundRuntime = env
case stateEnv.ID:
foundDeleted = env
}
}

Expect(foundRuntime).NotTo(BeNil())
Expect(foundRuntime.Status).To(Equal(StatusRunning))
Expect(foundDeleted).NotTo(BeNil())
Expect(foundDeleted.Status).To(Equal(EnvironmentStatus("deleted")))
})

It("marks state envs as unknown when runtime is offline", func() {
backend := &MockBackend{
mode: ModeDocker,
available: false,
}
mgr.RegisterBackend(backend)

stateEnv := &Environment{
ID: "env-offline",
Name: "offline",
Mode: ModeDocker,
Status: StatusStopped,
}
Expect(mgr.saveState(map[string]*Environment{stateEnv.ID: stateEnv})).To(Succeed())

envs, err := mgr.List(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(envs).To(HaveLen(1))
Expect(envs[0].ID).To(Equal(stateEnv.ID))
Expect(envs[0].Status).To(Equal(EnvironmentStatus("unknown")))
})
})
13 changes: 13 additions & 0 deletions internal/studio/studio_ginkgo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package studio

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestStudio(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Studio Suite")
}
2 changes: 2 additions & 0 deletions internal/studio/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ const (
StatusStopped EnvironmentStatus = "stopped"
StatusError EnvironmentStatus = "error"
StatusTerminated EnvironmentStatus = "terminated"
StatusDeleted EnvironmentStatus = "deleted"
StatusUnknown EnvironmentStatus = "unknown"
)

// Platform constants for runtime.GOOS comparisons
Expand Down
11 changes: 7 additions & 4 deletions internal/tui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import (
)

const (
statusYes = "yes"
statusYes = "yes"
statusIconCross = "✕"
)

// Theme defines the color palette for the TUI
Expand Down Expand Up @@ -203,7 +204,7 @@ func (s *Styles) StatusStyle(status string) lipgloss.Style {
switch status {
case "running", "active", "online", "connected", "enabled", statusYes:
return s.Success
case "stopped", "inactive", "offline", "disconnected", "disabled", "no":
case "stopped", "inactive", "offline", "disconnected", "disabled", "no", "deleted":
return s.Muted
case "error", "failed", "unhealthy":
return s.Error
Expand All @@ -224,13 +225,15 @@ func StatusIcon(status string) string {
case "stopped", "inactive", "offline", "disconnected":
return "○"
case "error", "failed", "unhealthy":
return "✕"
return statusIconCross
case "deleted":
return statusIconCross
case "starting", "stopping", "pending", "initializing":
return "◐"
case "enabled", statusYes:
return "✓"
case "disabled", "no":
return "✕"
return statusIconCross
case "unknown", "n/a":
return "◐"
default:
Expand Down