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
93 changes: 73 additions & 20 deletions src/internal/ui/serverdetail/serverdetail.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import (
"strings"
"time"

"github.com/larkly/lazystack/internal/compute"
"github.com/larkly/lazystack/internal/network"
"github.com/larkly/lazystack/internal/shared"
"github.com/larkly/lazystack/internal/volume"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/gophercloud/gophercloud/v2"
"github.com/larkly/lazystack/internal/compute"
"github.com/larkly/lazystack/internal/network"
"github.com/larkly/lazystack/internal/shared"
"github.com/larkly/lazystack/internal/volume"
)

type focusPane int
Expand All @@ -35,6 +35,15 @@ const (
maxConsoleLines = 500
)

func shouldPollDetailAPIs(status string) bool {
switch status {
case "SHUTOFF", "SHELVED", "SHELVED_OFFLOADED":
return false
default:
return true
}
}

type serverDetailLoadedMsg struct {
server *compute.Server
}
Expand Down Expand Up @@ -71,7 +80,6 @@ type volumeInfoLoadedMsg struct {
volumes map[string]*volume.Volume
}


// Model is the server detail dashboard view.
type Model struct {
client *gophercloud.ServiceClient
Expand All @@ -98,10 +106,10 @@ type Model struct {
actionsLoading bool
actionsErr string

interfaces []network.Port
interfacesScroll int
interfaces []network.Port
interfacesScroll int
interfacesLoading bool
interfacesErr string
interfacesErr string

volumeInfo map[string]*volume.Volume // volume ID → full volume data
volumeScroll int
Expand Down Expand Up @@ -145,6 +153,13 @@ func (m Model) Init() tea.Cmd {
return tea.Batch(cmds...)
}

func (m Model) canPollDetailAPIs() bool {
if m.server == nil {
return true
}
return shouldPollDetailAPIs(m.server.Status)
}

// ServerID returns the current server ID.
func (m Model) ServerID() string {
return m.serverID
Expand Down Expand Up @@ -229,6 +244,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil

case consoleLoadedMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] consoleLoadedMsg: %d chars", len(msg.output))
m.consoleLoading = false
m.consoleErr = ""
Expand All @@ -240,32 +258,47 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil

case consoleErrMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] consoleErrMsg: %v", msg.err)
m.consoleLoading = false
m.consoleErr = msg.err.Error()
return m, nil

case actionsLoadedMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] actionsLoadedMsg: %d actions", len(msg.actions))
m.actionsLoading = false
m.actionsErr = ""
m.actions = msg.actions
return m, nil

case actionsErrMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] actionsErrMsg: %v", msg.err)
m.actionsLoading = false
m.actionsErr = msg.err.Error()
return m, nil

case interfacesLoadedMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] interfacesLoadedMsg: %d ports", len(msg.ports))
m.interfacesLoading = false
m.interfacesErr = ""
m.interfaces = msg.ports
return m, nil

case interfacesErrMsg:
if !m.canPollDetailAPIs() {
return m, nil
}
shared.Debugf("[serverdetail] interfacesErrMsg: %v", msg.err)
m.interfacesLoading = false
m.interfacesErr = msg.err.Error()
Expand All @@ -282,10 +315,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
shared.Debugf("[serverdetail] tick skipped (loading)")
return m, nil
}
shared.Debugf("[serverdetail] tick fetching")
cmds := []tea.Cmd{m.fetchServer(), m.fetchConsole(), m.fetchActions()}
if m.networkClient != nil {
cmds = append(cmds, m.fetchInterfaces())
cmds := []tea.Cmd{m.fetchServer()}
if m.canPollDetailAPIs() {
shared.Debugf("[serverdetail] tick fetching full detail")
cmds = append(cmds, m.fetchConsole(), m.fetchActions())
if m.networkClient != nil {
cmds = append(cmds, m.fetchInterfaces())
}
} else {
shared.Debugf("[serverdetail] tick idling detail APIs for status %s", m.server.Status)
m.consoleLoading = false
m.actionsLoading = false
m.interfacesLoading = false
m.consoleErr = ""
m.actionsErr = ""
m.interfacesErr = ""
}
return m, tea.Batch(cmds...)

Expand Down Expand Up @@ -690,7 +734,7 @@ func (m Model) panelTitle(pane focusPane) string {
case focusVolumes:
return titleStyle.Render("Volumes")
case focusConsole:
t := titleStyle.Render("Console Log")
t := titleStyle.Render("Console Log (L)")
if m.consoleLoading {
t += " " + m.spinner.View()
}
Expand Down Expand Up @@ -1301,17 +1345,26 @@ func (m Model) fetchVolumeInfo(attachments []compute.VolumeAttachment) tea.Cmd {
}
}


// ForceRefresh triggers a manual reload of all data sources.
func (m *Model) ForceRefresh() tea.Cmd {
shared.Debugf("[serverdetail] ForceRefresh()")
m.loading = true
m.consoleLoading = true
m.actionsLoading = true
m.interfacesLoading = true
cmds := []tea.Cmd{m.spinner.Tick, m.fetchServer(), m.fetchConsole(), m.fetchActions()}
if m.networkClient != nil {
cmds = append(cmds, m.fetchInterfaces())
cmds := []tea.Cmd{m.spinner.Tick, m.fetchServer()}
if m.canPollDetailAPIs() {
m.consoleLoading = true
m.actionsLoading = true
m.interfacesLoading = m.networkClient != nil
cmds = append(cmds, m.fetchConsole(), m.fetchActions())
if m.networkClient != nil {
cmds = append(cmds, m.fetchInterfaces())
}
} else {
m.consoleLoading = false
m.actionsLoading = false
m.interfacesLoading = false
m.consoleErr = ""
m.actionsErr = ""
m.interfacesErr = ""
}
return tea.Batch(cmds...)
}
Expand Down
65 changes: 65 additions & 0 deletions src/internal/ui/serverdetail/serverdetail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package serverdetail

import (
"strings"
"testing"

"github.com/larkly/lazystack/internal/compute"
"github.com/larkly/lazystack/internal/shared"
)

func TestShouldPollDetailAPIs(t *testing.T) {
t.Parallel()

tests := []struct {
status string
want bool
}{
{status: "ACTIVE", want: true},
{status: "SHUTOFF", want: false},
{status: "SHELVED", want: false},
{status: "SHELVED_OFFLOADED", want: false},
}

for _, tc := range tests {
tc := tc
t.Run(tc.status, func(t *testing.T) {
t.Parallel()
if got := shouldPollDetailAPIs(tc.status); got != tc.want {
t.Fatalf("shouldPollDetailAPIs(%q) = %v, want %v", tc.status, got, tc.want)
}
})
}
}

func TestTickClearsConsoleErrorForIdleStatuses(t *testing.T) {
t.Parallel()

m := New(nil, nil, nil, "srv-1", 0)
m.loading = false
m.server = &compute.Server{ID: "srv-1", Status: "SHUTOFF"}
m.consoleErr = "previous console error"
m.actionsErr = "previous action error"
m.interfacesErr = "previous interface error"

updated, _ := m.Update(shared.TickMsg{})
if updated.consoleErr != "" {
t.Fatalf("consoleErr = %q, want empty", updated.consoleErr)
}
if updated.actionsErr != "" {
t.Fatalf("actionsErr = %q, want empty", updated.actionsErr)
}
if updated.interfacesErr != "" {
t.Fatalf("interfacesErr = %q, want empty", updated.interfacesErr)
}
}

func TestPanelTitleShowsConsoleHotkey(t *testing.T) {
t.Parallel()

m := New(nil, nil, nil, "srv-1", 0)
title := m.panelTitle(focusConsole)
if !strings.Contains(title, "Console Log (L)") {
t.Fatalf("panel title %q does not include Console Log (L)", title)
}
}
Loading