Skip to content

Commit cc07b29

Browse files
authored
Merge pull request #2 from NexusGPU/codex/studio-list-runtime-offline
fix: studio list runtime offline status
2 parents 6f4bc2b + 75b366c commit cc07b29

7 files changed

Lines changed: 210 additions & 9 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,5 @@ bin/
5959

6060
*.tmp
6161

62+
63+
.worktrees/

cmd/ggo/studio/studio.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"flag"
66
"fmt"
7+
"sort"
78
"strings"
89

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

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

521-
sshInfo := styles.Muted.Render("-")
522-
if env.SSHPort > 0 {
523+
sshInfo := styles.Muted.Render("N/A")
524+
if env.Status == studio.StatusUnknown {
525+
offlineModes[string(env.Mode)] = struct{}{}
526+
}
527+
if env.Status != studio.StatusUnknown && env.Status != studio.StatusDeleted && env.SSHPort > 0 && env.SSHHost != "" {
523528
sshInfo = fmt.Sprintf("%s:%d", env.SSHHost, env.SSHPort)
524529
}
525530

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

540545
out.Println(table.String())
546+
547+
if len(offlineModes) > 0 {
548+
modes := make([]string, 0, len(offlineModes))
549+
for mode := range offlineModes {
550+
modes = append(modes, mode)
551+
}
552+
sort.Strings(modes)
553+
out.Println()
554+
out.Warning(fmt.Sprintf("Container runtime offline: %s", strings.Join(modes, ", ")))
555+
}
541556
}
542557

543558
func newStartCmd() *cobra.Command {

internal/studio/manager.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"path/filepath"
1010
"runtime"
11+
"sort"
1112
"strings"
1213
"sync"
1314

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

196-
var allEnvs []*Environment
197+
state, err := m.loadState()
198+
if err != nil {
199+
state = make(map[string]*Environment)
200+
}
201+
202+
runtimeByMode := make(map[Mode][]*Environment)
203+
runtimeIDs := make(map[Mode]map[string]struct{})
204+
offlineModes := make(map[Mode]struct{})
205+
197206
for _, backend := range m.backends {
207+
mode := backend.Mode()
198208
if !backend.IsAvailable(ctx) {
209+
offlineModes[mode] = struct{}{}
199210
continue
200211
}
201212
envs, err := backend.List(ctx)
202213
if err != nil {
203-
continue // Log but continue with other backends
214+
offlineModes[mode] = struct{}{}
215+
continue
216+
}
217+
runtimeByMode[mode] = envs
218+
ids := make(map[string]struct{}, len(envs))
219+
for _, env := range envs {
220+
ids[env.ID] = struct{}{}
221+
}
222+
runtimeIDs[mode] = ids
223+
}
224+
225+
var allEnvs []*Environment
226+
includedIDs := make(map[string]struct{})
227+
for _, envs := range runtimeByMode {
228+
for _, env := range envs {
229+
allEnvs = append(allEnvs, env)
230+
includedIDs[env.ID] = struct{}{}
231+
}
232+
}
233+
234+
stateEnvs := make([]*Environment, 0, len(state))
235+
for _, env := range state {
236+
stateEnvs = append(stateEnvs, env)
237+
}
238+
sort.Slice(stateEnvs, func(i, j int) bool {
239+
if stateEnvs[i].Name == stateEnvs[j].Name {
240+
return stateEnvs[i].ID < stateEnvs[j].ID
241+
}
242+
return stateEnvs[i].Name < stateEnvs[j].Name
243+
})
244+
245+
for _, env := range stateEnvs {
246+
if _, ok := includedIDs[env.ID]; ok {
247+
continue
248+
}
249+
250+
envCopy := cloneEnvironment(env)
251+
if _, offline := offlineModes[env.Mode]; offline {
252+
envCopy.Status = StatusUnknown
253+
} else if _, ok := runtimeIDs[env.Mode]; ok {
254+
envCopy.Status = StatusDeleted
255+
} else {
256+
envCopy.Status = StatusUnknown
204257
}
205-
allEnvs = append(allEnvs, envs...)
258+
allEnvs = append(allEnvs, envCopy)
206259
}
207260

208261
return allEnvs, nil
209262
}
210263

264+
func cloneEnvironment(env *Environment) *Environment {
265+
if env == nil {
266+
return nil
267+
}
268+
copyEnv := *env
269+
if env.Labels != nil {
270+
labels := make(map[string]string, len(env.Labels))
271+
for k, v := range env.Labels {
272+
labels[k] = v
273+
}
274+
copyEnv.Labels = labels
275+
}
276+
return &copyEnv
277+
}
278+
211279
// Stop stops an environment
212280
func (m *Manager) Stop(ctx context.Context, idOrName string) error {
213281
env, err := m.Get(ctx, idOrName)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package studio
2+
3+
import (
4+
"context"
5+
"os"
6+
7+
"github.com/NexusGPU/gpu-go/internal/platform"
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
var _ = Describe("Manager List", func() {
13+
var (
14+
mgr *Manager
15+
tmpDir string
16+
)
17+
18+
BeforeEach(func() {
19+
var err error
20+
tmpDir, err = os.MkdirTemp("", "ggo-studio-list-*")
21+
Expect(err).NotTo(HaveOccurred())
22+
DeferCleanup(func() {
23+
_ = os.RemoveAll(tmpDir)
24+
})
25+
26+
mgr = &Manager{
27+
paths: platform.DefaultPaths().WithConfigDir(tmpDir),
28+
backends: make(map[Mode]Backend),
29+
}
30+
})
31+
32+
It("marks state-only envs as deleted when runtime is available", func() {
33+
runtimeEnv := &Environment{
34+
ID: "env-runtime",
35+
Name: "runtime",
36+
Mode: ModeDocker,
37+
Status: StatusRunning,
38+
}
39+
backend := &MockBackend{
40+
mode: ModeDocker,
41+
available: true,
42+
listFunc: func(ctx context.Context) ([]*Environment, error) {
43+
return []*Environment{runtimeEnv}, nil
44+
},
45+
}
46+
mgr.RegisterBackend(backend)
47+
48+
stateEnv := &Environment{
49+
ID: "env-deleted",
50+
Name: "deleted",
51+
Mode: ModeDocker,
52+
Status: StatusStopped,
53+
}
54+
Expect(mgr.saveState(map[string]*Environment{stateEnv.ID: stateEnv})).To(Succeed())
55+
56+
envs, err := mgr.List(context.Background())
57+
Expect(err).NotTo(HaveOccurred())
58+
Expect(envs).To(HaveLen(2))
59+
60+
var foundRuntime *Environment
61+
var foundDeleted *Environment
62+
for _, env := range envs {
63+
switch env.ID {
64+
case runtimeEnv.ID:
65+
foundRuntime = env
66+
case stateEnv.ID:
67+
foundDeleted = env
68+
}
69+
}
70+
71+
Expect(foundRuntime).NotTo(BeNil())
72+
Expect(foundRuntime.Status).To(Equal(StatusRunning))
73+
Expect(foundDeleted).NotTo(BeNil())
74+
Expect(foundDeleted.Status).To(Equal(EnvironmentStatus("deleted")))
75+
})
76+
77+
It("marks state envs as unknown when runtime is offline", func() {
78+
backend := &MockBackend{
79+
mode: ModeDocker,
80+
available: false,
81+
}
82+
mgr.RegisterBackend(backend)
83+
84+
stateEnv := &Environment{
85+
ID: "env-offline",
86+
Name: "offline",
87+
Mode: ModeDocker,
88+
Status: StatusStopped,
89+
}
90+
Expect(mgr.saveState(map[string]*Environment{stateEnv.ID: stateEnv})).To(Succeed())
91+
92+
envs, err := mgr.List(context.Background())
93+
Expect(err).NotTo(HaveOccurred())
94+
Expect(envs).To(HaveLen(1))
95+
Expect(envs[0].ID).To(Equal(stateEnv.ID))
96+
Expect(envs[0].Status).To(Equal(EnvironmentStatus("unknown")))
97+
})
98+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package studio
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
func TestStudio(t *testing.T) {
11+
RegisterFailHandler(Fail)
12+
RunSpecs(t, "Studio Suite")
13+
}

internal/studio/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ const (
9595
StatusStopped EnvironmentStatus = "stopped"
9696
StatusError EnvironmentStatus = "error"
9797
StatusTerminated EnvironmentStatus = "terminated"
98+
StatusDeleted EnvironmentStatus = "deleted"
99+
StatusUnknown EnvironmentStatus = "unknown"
98100
)
99101

100102
// Platform constants for runtime.GOOS comparisons

internal/tui/theme.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import (
66
)
77

88
const (
9-
statusYes = "yes"
9+
statusYes = "yes"
10+
statusIconCross = "✕"
1011
)
1112

1213
// Theme defines the color palette for the TUI
@@ -203,7 +204,7 @@ func (s *Styles) StatusStyle(status string) lipgloss.Style {
203204
switch status {
204205
case "running", "active", "online", "connected", "enabled", statusYes:
205206
return s.Success
206-
case "stopped", "inactive", "offline", "disconnected", "disabled", "no":
207+
case "stopped", "inactive", "offline", "disconnected", "disabled", "no", "deleted":
207208
return s.Muted
208209
case "error", "failed", "unhealthy":
209210
return s.Error
@@ -224,13 +225,15 @@ func StatusIcon(status string) string {
224225
case "stopped", "inactive", "offline", "disconnected":
225226
return "○"
226227
case "error", "failed", "unhealthy":
227-
return "✕"
228+
return statusIconCross
229+
case "deleted":
230+
return statusIconCross
228231
case "starting", "stopping", "pending", "initializing":
229232
return "◐"
230233
case "enabled", statusYes:
231234
return "✓"
232235
case "disabled", "no":
233-
return "✕"
236+
return statusIconCross
234237
case "unknown", "n/a":
235238
return "◐"
236239
default:

0 commit comments

Comments
 (0)