From e76f99f416d407b43fdf89f01c9ce3eea251b235 Mon Sep 17 00:00:00 2001 From: MUHAMMAD FARIS EL HAKIM Date: Tue, 28 Apr 2026 14:46:09 +0000 Subject: [PATCH] feat(lab-envs): TUI view for backend-agnostic ephemeral lab envs Adds a Lab Envs view to the Bubble Tea client mirroring the concave-web /lab/envs page. Lists running envs with live TTL countdown, supports extend and archive actions gated by role. - internal/client: LabEnv, LabStorage, LabEnvsResponse, LabLaunchRequest types and Client.LabEnvs / LabLaunch / LabExtend / LabArchive methods. - cmd/concave-tui/model/api_bindings.go: apiLabEnvsFn / apiLabLaunchFn / apiLabExtendFn / apiLabArchiveFn test seams. - cmd/concave-tui/model/lab_envs.go: LabEnvsModel with 10s refresh, 1s countdown tick, viewer/developer/operator role gating, keybindings (r refresh, j/k select, e extend +1h, a archive). - cmd/concave-tui/model/root.go: ViewLabEnvs slot, visibleViews wiring, Lab Envs help entry. - Tests: formatTTL + LabEnvsModel table rendering + role-gated extend. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- cmd/concave-tui/model/api_bindings.go | 12 + cmd/concave-tui/model/lab_envs.go | 326 +++++++++++++++++++++++++ cmd/concave-tui/model/lab_envs_test.go | 101 ++++++++ cmd/concave-tui/model/root.go | 31 ++- cmd/concave-tui/model/root_test.go | 6 +- internal/client/client.go | 91 +++++++ 6 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 cmd/concave-tui/model/lab_envs.go create mode 100644 cmd/concave-tui/model/lab_envs_test.go diff --git a/cmd/concave-tui/model/api_bindings.go b/cmd/concave-tui/model/api_bindings.go index 4698777..7456c0d 100644 --- a/cmd/concave-tui/model/api_bindings.go +++ b/cmd/concave-tui/model/api_bindings.go @@ -75,6 +75,18 @@ var ( apiChangelogFn = func(ctx context.Context, name string) (apiclient.ChangelogResponse, error) { return sharedClient.Changelog(ctx, name) } + apiLabEnvsFn = func(ctx context.Context) (apiclient.LabEnvsResponse, error) { + return sharedClient.LabEnvs(ctx) + } + apiLabLaunchFn = func(ctx context.Context, payload apiclient.LabLaunchRequest) (apiclient.LabEnv, error) { + return sharedClient.LabLaunch(ctx, payload) + } + apiLabExtendFn = func(ctx context.Context, id string, extendSeconds int) (apiclient.LabEnv, error) { + return sharedClient.LabExtend(ctx, id, extendSeconds) + } + apiLabArchiveFn = func(ctx context.Context, id string) (apiclient.LabEnv, error) { + return sharedClient.LabArchive(ctx, id) + } apiLogsDialFn = func(ctx context.Context, suiteName, container string) (logStream, error) { conn, err := sharedClient.DialLogs(ctx, suiteName, container) if err != nil { diff --git a/cmd/concave-tui/model/lab_envs.go b/cmd/concave-tui/model/lab_envs.go new file mode 100644 index 0000000..8683d7e --- /dev/null +++ b/cmd/concave-tui/model/lab_envs.go @@ -0,0 +1,326 @@ +package model + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + tuiauth "github.com/Gradient-Linux/concave-tui/internal/auth" + apiclient "github.com/Gradient-Linux/concave-tui/internal/client" +) + +const ( + labEnvsRefreshInterval = 10 * time.Second + labEnvsCountdownTick = 1 * time.Second +) + +type labEnvsLoadedMsg struct { + token int + response apiclient.LabEnvsResponse + err error +} + +type labEnvsActionMsg struct { + token int + action string + envID string + err error +} + +type labEnvsTickMsg struct { + token int +} + +type labEnvsCountdownMsg struct { + token int +} + +type LabEnvsModel struct { + width int + height int + active bool + role tuiauth.Role + loading bool + token int + selected int + response apiclient.LabEnvsResponse + now time.Time + busy bool + lastErr error + lastInfo string +} + +// NewLabEnvsModel returns a fresh lab envs model. +func NewLabEnvsModel() LabEnvsModel { + return LabEnvsModel{loading: true, now: time.Now()} +} + +// SetRole applies the current session role. +func (m *LabEnvsModel) SetRole(role tuiauth.Role) { + m.role = role +} + +// Activate triggers a fresh load and starts the refresh + countdown timers. +func (m *LabEnvsModel) Activate() tea.Cmd { + m.active = true + m.loading = true + m.token++ + token := m.token + return tea.Batch( + loadLabEnvsCmd(token), + labEnvsTickCmd(token, labEnvsRefreshInterval), + labEnvsCountdownCmd(token), + ) +} + +// Deactivate stops refreshes. +func (m *LabEnvsModel) Deactivate() { + m.active = false + m.token++ +} + +// SetSize updates the layout bounds. +func (m *LabEnvsModel) SetSize(width, height int) { + m.width = width + m.height = height +} + +// Update processes tea messages for the lab envs view. +func (m LabEnvsModel) Update(msg tea.Msg) (LabEnvsModel, tea.Cmd) { + if m.role < tuiauth.RoleViewer { + return m, nil + } + switch msg := msg.(type) { + case labEnvsLoadedMsg: + if msg.token != m.token { + return m, nil + } + m.loading = false + m.response = msg.response + m.lastErr = msg.err + if m.selected >= len(m.response.Envs) && len(m.response.Envs) > 0 { + m.selected = len(m.response.Envs) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + case labEnvsActionMsg: + if msg.token != m.token { + return m, nil + } + m.busy = false + if msg.err != nil { + m.lastErr = msg.err + } else { + m.lastErr = nil + m.lastInfo = fmt.Sprintf("%s %s", msg.action, msg.envID) + } + m.token++ + return m, loadLabEnvsCmd(m.token) + case labEnvsTickMsg: + if m.active && msg.token == m.token { + return m, tea.Batch(loadLabEnvsCmd(msg.token), labEnvsTickCmd(msg.token, labEnvsRefreshInterval)) + } + case labEnvsCountdownMsg: + if m.active && msg.token == m.token { + m.now = time.Now() + return m, labEnvsCountdownCmd(msg.token) + } + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m LabEnvsModel) handleKey(msg tea.KeyMsg) (LabEnvsModel, tea.Cmd) { + switch msg.String() { + case "r": + m.token++ + m.loading = true + return m, loadLabEnvsCmd(m.token) + case "j", "down": + if m.selected < len(m.response.Envs)-1 { + m.selected++ + } + case "k", "up": + if m.selected > 0 { + m.selected-- + } + case "e": + if m.role < tuiauth.RoleDeveloper { + return m, nil + } + env := m.selectedEnv() + if env == nil || m.busy { + return m, nil + } + m.busy = true + m.token++ + return m, extendLabEnvCmd(m.token, env.ID, 3600) + case "a": + if m.role < tuiauth.RoleOperator { + return m, nil + } + env := m.selectedEnv() + if env == nil || m.busy { + return m, nil + } + m.busy = true + m.token++ + return m, archiveLabEnvCmd(m.token, env.ID) + } + return m, nil +} + +// View renders the lab envs page. +func (m LabEnvsModel) View() string { + if m.role < tuiauth.RoleViewer { + return mutedText("Lab envs are available to Viewer and above") + } + if m.loading && len(m.response.Envs) == 0 && m.lastErr == nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Render("Loading lab envs…") + } + + var lines []string + lines = append(lines, m.renderHeader(), "") + lines = append(lines, m.renderTable()...) + if sel := m.selectedEnv(); sel != nil { + lines = append(lines, "", "Selected env") + lines = append(lines, m.renderDetails(*sel)...) + } else if len(m.response.Envs) == 0 { + lines = append(lines, mutedText("No lab envs yet. Launch one via `concave lab envs launch`.")) + } + if m.lastInfo != "" { + lines = append(lines, "", mutedText(m.lastInfo)) + } + if m.lastErr != nil { + lines = append(lines, "", errorText(m.lastErr.Error())) + } + return strings.Join(lines, "\n") +} + +// HelpView summarises keybindings. +func (m LabEnvsModel) HelpView() string { + return "Lab Envs\nr refresh · j/k select · e extend +1h (dev) · a archive (ops)" +} + +func (m LabEnvsModel) renderHeader() string { + return fmt.Sprintf("Lab Envs — driver %s · hot %s · cold %s", + fallbackText(m.response.Active, "docker"), + fallbackText(m.response.Storage.HotTier, "unset"), + fallbackText(m.response.Storage.ColdTier, "unset"), + ) +} + +func (m LabEnvsModel) renderTable() []string { + lines := []string{"ID OWNER DRIVER STATUS IMAGE TTL"} + if len(m.response.Envs) == 0 { + return lines + } + for idx, env := range m.response.Envs { + prefix := " " + if idx == m.selected { + prefix = ">" + } + lines = append(lines, fmt.Sprintf("%s %-15s %-9s %-9s %-10s %-29s %s", + prefix, + truncate(env.ID, 15), + truncate(fallbackText(env.Owner, "-"), 9), + truncate(fallbackText(env.Driver, "-"), 9), + truncate(env.Status, 10), + truncate(env.Image, 29), + formatTTL(m.now, env.ExpiresAt), + )) + } + return lines +} + +func (m LabEnvsModel) renderDetails(env apiclient.LabEnv) []string { + lines := []string{ + fmt.Sprintf("ID: %s", env.ID), + fmt.Sprintf("Jupyter: %s", fallbackText(env.JupyterURL, "unavailable")), + fmt.Sprintf("Hot tier: %s", fallbackText(env.HotTierPath, "-")), + fmt.Sprintf("Cold tier: %s", fallbackText(env.ColdTierPath, "-")), + } + if env.ArchiveRef != "" { + lines = append(lines, fmt.Sprintf("Archive: %s", env.ArchiveRef)) + } + if env.LastError != "" { + lines = append(lines, errorText(env.LastError)) + } + return lines +} + +func (m LabEnvsModel) selectedEnv() *apiclient.LabEnv { + if m.selected < 0 || m.selected >= len(m.response.Envs) { + return nil + } + env := m.response.Envs[m.selected] + return &env +} + +func formatTTL(now time.Time, expiresAt time.Time) string { + if expiresAt.IsZero() { + return "—" + } + remaining := time.Until(expiresAt) + if !now.IsZero() { + remaining = expiresAt.Sub(now) + } + if remaining <= 0 { + return "expired" + } + hours := int(remaining / time.Hour) + minutes := int((remaining % time.Hour) / time.Minute) + seconds := int((remaining % time.Minute) / time.Second) + if hours > 0 { + return fmt.Sprintf("%dh %02dm", hours, minutes) + } + if minutes > 0 { + return fmt.Sprintf("%dm %02ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +func loadLabEnvsCmd(token int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + response, err := apiLabEnvsFn(ctx) + return labEnvsLoadedMsg{token: token, response: response, err: err} + } +} + +func extendLabEnvCmd(token int, id string, seconds int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, err := apiLabExtendFn(ctx, id, seconds) + return labEnvsActionMsg{token: token, action: "extend", envID: id, err: err} + } +} + +func archiveLabEnvCmd(token int, id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, err := apiLabArchiveFn(ctx, id) + return labEnvsActionMsg{token: token, action: "archive", envID: id, err: err} + } +} + +func labEnvsTickCmd(token int, delay time.Duration) tea.Cmd { + return tea.Tick(delay, func(time.Time) tea.Msg { + return labEnvsTickMsg{token: token} + }) +} + +func labEnvsCountdownCmd(token int) tea.Cmd { + return tea.Tick(labEnvsCountdownTick, func(time.Time) tea.Msg { + return labEnvsCountdownMsg{token: token} + }) +} diff --git a/cmd/concave-tui/model/lab_envs_test.go b/cmd/concave-tui/model/lab_envs_test.go new file mode 100644 index 0000000..f936dd7 --- /dev/null +++ b/cmd/concave-tui/model/lab_envs_test.go @@ -0,0 +1,101 @@ +package model + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tuiauth "github.com/Gradient-Linux/concave-tui/internal/auth" + apiclient "github.com/Gradient-Linux/concave-tui/internal/client" +) + +func TestFormatTTLRendersRemainingTime(t *testing.T) { + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + cases := map[string]struct { + expires time.Time + want string + }{ + "hours": {base.Add(3*time.Hour + 15*time.Minute), "3h 15m"}, + "minutes": {base.Add(15*time.Minute + 7*time.Second), "15m 07s"}, + "seconds": {base.Add(42 * time.Second), "42s"}, + "expired": {base.Add(-1 * time.Minute), "expired"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := formatTTL(base, tc.expires); got != tc.want { + t.Fatalf("formatTTL(%v) = %q, want %q", tc.expires, got, tc.want) + } + }) + } +} + +func TestLabEnvsModelRendersTableAndBlocksViewers(t *testing.T) { + restoreModelDeps(t) + + restoreLabEnvs := apiLabEnvsFn + t.Cleanup(func() { apiLabEnvsFn = restoreLabEnvs }) + expires := time.Now().Add(2 * time.Hour) + apiLabEnvsFn = func(ctx context.Context) (apiclient.LabEnvsResponse, error) { + return apiclient.LabEnvsResponse{ + Envs: []apiclient.LabEnv{ + {ID: "env-aaa", Driver: "docker", Owner: "alice", Image: "jupyter/min", Status: "running", ExpiresAt: expires}, + }, + Storage: apiclient.LabStorage{HotTier: "/hot", ColdTier: "/cold"}, + Drivers: []string{"docker"}, + Active: "docker", + }, nil + } + + m := NewLabEnvsModel() + m.SetRole(tuiauth.RoleDeveloper) + m.Activate() + msg := loadLabEnvsCmd(m.token)() + m, _ = m.Update(msg) + + view := m.View() + if !strings.Contains(view, "env-aaa") || !strings.Contains(view, "docker") { + t.Fatalf("expected env row in view, got: %q", view) + } + if !strings.Contains(view, "/cold") { + t.Fatalf("expected cold tier in header, got: %q", view) + } + +} + +func TestLabEnvsExtendRequiresDeveloper(t *testing.T) { + restoreModelDeps(t) + + restoreExtend := apiLabExtendFn + t.Cleanup(func() { apiLabExtendFn = restoreExtend }) + called := 0 + apiLabExtendFn = func(ctx context.Context, id string, seconds int) (apiclient.LabEnv, error) { + called++ + if id != "env-bbb" || seconds != 3600 { + return apiclient.LabEnv{}, errors.New("unexpected args") + } + return apiclient.LabEnv{ID: id}, nil + } + + m := NewLabEnvsModel() + m.SetRole(tuiauth.RoleViewer) + m.response = apiclient.LabEnvsResponse{Envs: []apiclient.LabEnv{{ID: "env-bbb", Driver: "docker", Status: "running"}}} + + m, _ = m.Update(keyRunes("e")) + if called != 0 { + t.Fatalf("viewer should not trigger extend, called=%d", called) + } + + m.SetRole(tuiauth.RoleDeveloper) + _, cmd := m.Update(keyRunes("e")) + if cmd == nil { + t.Fatalf("developer extend should produce a command") + } + if msg := cmd(); msg == nil { + t.Fatalf("extend command returned nil msg") + } + if called != 1 { + t.Fatalf("extend called=%d, want 1", called) + } +} diff --git a/cmd/concave-tui/model/root.go b/cmd/concave-tui/model/root.go index 7bac563..7dcc44f 100644 --- a/cmd/concave-tui/model/root.go +++ b/cmd/concave-tui/model/root.go @@ -101,6 +101,7 @@ const ( ViewDoctor ViewEnvironment ViewFleet + ViewLabEnvs ViewTeams ViewSystem ViewUsers @@ -127,6 +128,7 @@ type RootModel struct { doctor DoctorModel environment EnvironmentModel fleet FleetModel + labEnvs LabEnvsModel teams TeamsModel system SystemModel users UsersModel @@ -182,6 +184,7 @@ func NewRootModel(version string, cfgs ...any) *RootModel { doctor: NewDoctorModel(), environment: NewEnvironmentModel(), fleet: NewFleetModel(), + labEnvs: NewLabEnvsModel(), teams: NewTeamsModel(), system: NewSystemModel(), users: NewUsersModel(), @@ -267,6 +270,8 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.environment, cmd = m.environment.Update(msg) case ViewFleet: m.fleet, cmd = m.fleet.Update(msg) + case ViewLabEnvs: + m.labEnvs, cmd = m.labEnvs.Update(msg) case ViewTeams: m.teams, cmd = m.teams.Update(msg) case ViewSystem: @@ -306,6 +311,7 @@ func (m *RootModel) applySession(session tuiauth.Session) { m.doctor.SetRole(session.Role) m.environment.SetRole(session.Role) m.fleet.SetRole(session.Role) + m.labEnvs.SetRole(session.Role) m.teams.SetRole(session.Role) m.system.SetRole(session.Role) m.users.SetRole(session.Role) @@ -434,6 +440,8 @@ func (m *RootModel) deactivateView(view View) { m.environment.Deactivate() case ViewFleet: m.fleet.Deactivate() + case ViewLabEnvs: + m.labEnvs.Deactivate() case ViewTeams: m.teams.Deactivate() case ViewSystem: @@ -457,6 +465,8 @@ func (m *RootModel) activateView(view View) tea.Cmd { return m.environment.Activate() case ViewFleet: return m.fleet.Activate() + case ViewLabEnvs: + return m.labEnvs.Activate() case ViewTeams: return m.teams.Activate() case ViewSystem: @@ -484,6 +494,7 @@ func (m *RootModel) applyLayout() { m.doctor.SetSize(contentWidth, contentHeight) m.environment.SetSize(contentWidth, contentHeight) m.fleet.SetSize(contentWidth, contentHeight) + m.labEnvs.SetSize(contentWidth, contentHeight) m.teams.SetSize(contentWidth, contentHeight) m.system.SetSize(contentWidth, contentHeight) m.users.SetSize(contentWidth, contentHeight) @@ -572,6 +583,8 @@ func (m *RootModel) activeContent() string { return m.environment.View() case ViewFleet: return m.fleet.View() + case ViewLabEnvs: + return m.labEnvs.View() case ViewTeams: return m.teams.View() case ViewSystem: @@ -679,6 +692,18 @@ func (m *RootModel) activeHelpActions() []string { "r refresh fleet", "j / k move peer selection", } + case ViewLabEnvs: + actions := []string{ + "r refresh envs", + "j / k select env", + } + if tuiauth.Can(m.session.Role, tuiauth.ActionStart) { + actions = append(actions, "e extend +1h") + } + if tuiauth.Can(m.session.Role, tuiauth.ActionStop) { + actions = append(actions, "a archive now") + } + return actions case ViewTeams: return []string{ "r refresh teams", @@ -829,7 +854,7 @@ func sidebarStateFromConfig(cfg tuiconfig.Config) SidebarState { } func (m RootModel) visibleViews() []View { - views := []View{ViewWorkspace, ViewSuites, ViewLogs, ViewDoctor, ViewEnvironment, ViewFleet} + views := []View{ViewWorkspace, ViewSuites, ViewLogs, ViewDoctor, ViewEnvironment, ViewFleet, ViewLabEnvs} if m.session.Role >= tuiauth.RoleAdmin { views = append(views, ViewTeams, ViewSystem, ViewUsers) } @@ -883,6 +908,8 @@ func sidebarIcon(view View) string { return "EN" case ViewFleet: return "FL" + case ViewLabEnvs: + return "LE" case ViewTeams: return "TM" case ViewSystem: @@ -908,6 +935,8 @@ func sidebarLabel(view View) string { return "Environment" case ViewFleet: return "Fleet" + case ViewLabEnvs: + return "Lab Envs" case ViewTeams: return "Teams" case ViewSystem: diff --git a/cmd/concave-tui/model/root_test.go b/cmd/concave-tui/model/root_test.go index 729feab..d1d2212 100644 --- a/cmd/concave-tui/model/root_test.go +++ b/cmd/concave-tui/model/root_test.go @@ -107,7 +107,7 @@ func TestRootSwitchesViewsWhenAuthenticated(t *testing.T) { t.Fatalf("activeView = %v, want %v", root.activeView, ViewSuites) } - updated, _ = root.Update(keyRunes("8")) + updated, _ = root.Update(keyRunes("9")) root = updated.(*RootModel) if root.activeView != ViewSystem { t.Fatalf("activeView = %v, want %v", root.activeView, ViewSystem) @@ -175,7 +175,7 @@ func TestHelpOverlayShowsRoleFilteredActions(t *testing.T) { func TestAdminVisibleViewsIncludeSystemAndUsers(t *testing.T) { m := NewRootModel("dev", testConfig(), authSession(tuiauth.RoleAdmin)) views := m.visibleViews() - if len(views) != 9 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewTeams || views[7] != ViewSystem || views[8] != ViewUsers { + if len(views) != 10 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewLabEnvs || views[7] != ViewTeams || views[8] != ViewSystem || views[9] != ViewUsers { t.Fatalf("visibleViews() = %#v", views) } } @@ -183,7 +183,7 @@ func TestAdminVisibleViewsIncludeSystemAndUsers(t *testing.T) { func TestViewerVisibleViewsIncludeMonitoringScreens(t *testing.T) { m := NewRootModel("dev", testConfig(), authSession(tuiauth.RoleViewer)) views := m.visibleViews() - if len(views) != 6 || views[4] != ViewEnvironment || views[5] != ViewFleet { + if len(views) != 7 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewLabEnvs { t.Fatalf("visibleViews() = %#v", views) } } diff --git a/internal/client/client.go b/internal/client/client.go index 838afe6..dcf6035 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -620,6 +620,97 @@ func (c *Client) do(req *http.Request, out any) error { return nil } +type LabEnv struct { + ID string `json:"id"` + Driver string `json:"driver"` + Owner string `json:"owner"` + Image string `json:"image"` + DisplayName string `json:"display_name,omitempty"` + Status string `json:"status"` + JupyterURL string `json:"jupyter_url,omitempty"` + Token string `json:"token,omitempty"` + GPUs int `json:"gpus"` + CPURequest string `json:"cpu_request,omitempty"` + MemRequest string `json:"mem_request,omitempty"` + HotTierPath string `json:"hot_tier_path"` + ColdTierPath string `json:"cold_tier_path"` + ArchiveRef string `json:"archive_ref,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + LastError string `json:"last_error,omitempty"` +} + +type LabStorage struct { + HotTier string `json:"hot_tier"` + ColdTier string `json:"cold_tier"` +} + +type LabEnvsResponse struct { + Envs []LabEnv `json:"envs"` + Storage LabStorage `json:"storage"` + Drivers []string `json:"drivers"` + Active string `json:"active"` +} + +type LabLaunchRequest struct { + Image string `json:"image"` + DisplayName string `json:"display_name,omitempty"` + Driver string `json:"driver,omitempty"` + GPUs int `json:"gpus,omitempty"` + CPURequest string `json:"cpu_request,omitempty"` + MemRequest string `json:"mem_request,omitempty"` + TTLSeconds int `json:"ttl_seconds"` +} + +func (c *Client) LabEnvs(ctx context.Context) (LabEnvsResponse, error) { + var response LabEnvsResponse + req, err := c.newJSONRequest(ctx, http.MethodGet, "/api/v1/lab/envs", nil) + if err != nil { + return LabEnvsResponse{}, err + } + if err := c.do(req, &response); err != nil { + return LabEnvsResponse{}, err + } + return response, nil +} + +func (c *Client) LabLaunch(ctx context.Context, payload LabLaunchRequest) (LabEnv, error) { + var response LabEnv + req, err := c.newJSONRequest(ctx, http.MethodPost, "/api/v1/lab/envs", payload) + if err != nil { + return LabEnv{}, err + } + if err := c.do(req, &response); err != nil { + return LabEnv{}, err + } + return response, nil +} + +func (c *Client) LabExtend(ctx context.Context, id string, extendSeconds int) (LabEnv, error) { + var response LabEnv + body := map[string]int{"extend_seconds": extendSeconds} + req, err := c.newJSONRequest(ctx, http.MethodPost, "/api/v1/lab/envs/"+url.PathEscape(id)+"/extend", body) + if err != nil { + return LabEnv{}, err + } + if err := c.do(req, &response); err != nil { + return LabEnv{}, err + } + return response, nil +} + +func (c *Client) LabArchive(ctx context.Context, id string) (LabEnv, error) { + var response LabEnv + req, err := c.newJSONRequest(ctx, http.MethodPost, "/api/v1/lab/envs/"+url.PathEscape(id)+"/archive", nil) + if err != nil { + return LabEnv{}, err + } + if err := c.do(req, &response); err != nil { + return LabEnv{}, err + } + return response, nil +} + func IsUnauthorized(err error) bool { var apiErr *APIError if !errors.As(err, &apiErr) {