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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ SOURCE_GIT_COMMIT ?= $(shell git rev-parse --verify 'HEAD^{commit}')
BUILD_VERSION ?= $(shell git describe --always --abbrev=40 --dirty)

VERSION_URI ?= github.com/openshift/agent-installer-utils/pkg/version
RELEASE_IMAGE ?= quay.io/openshift-release-dev/ocp-release:4.18.4-x86_64
RELEASE_IMAGE ?= quay.io/openshift-release-dev/ocp-release:4.21.1-x86_64
ARCH ?= x86_64

.PHONY:clean
Expand All @@ -14,6 +14,10 @@ clean:
lint:
golangci-lint run -v

.PHONY: test
test:
cd tools/agent_tui && go test -v ./...

.PHONY: build
build: clean lint
hack/build.sh ${VERSION_URI} ${SOURCE_GIT_COMMIT} ${BUILD_VERSION}
Expand Down
26 changes: 23 additions & 3 deletions tools/agent_tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,26 @@ import (
"github.com/sirupsen/logrus"
)

func App(app *tview.Application, rendezvousIP string, config checks.Config, checkFuncs ...checks.CheckFunctions) {
// AppContext contains all the configuration and state needed to run the agent TUI application
type AppContext struct {
// App is the tview application instance. If nil, a new one will be created.
App *tview.Application
// RendezvousIP is the pre-configured rendezvous IP address, if available
RendezvousIP string
// InteractiveUIMode indicates whether the interactive UI is enabled
InteractiveUIMode bool
// Config contains the checks configuration
Config checks.Config
// CheckFuncs allows injecting custom check implementations for testing
CheckFuncs []checks.CheckFunctions
}

func App(ctx AppContext) {
app := ctx.App
rendezvousIP := ctx.RendezvousIP
interactiveUIMode := ctx.InteractiveUIMode
config := ctx.Config
checkFuncs := ctx.CheckFuncs

if err := prepareConfig(&config); err != nil {
log.Fatal(err)
Expand All @@ -34,6 +53,7 @@ func App(app *tview.Application, rendezvousIP string, config checks.Config, chec
logger.Infof("Agent TUI git version: %s", version.Commit)
logger.Infof("Agent TUI build version: %s", version.Raw)
logger.Infof("Rendezvous IP: %s", rendezvousIP)
logger.Infof("Interactive UI Mode: %v", interactiveUIMode)

var appUI *ui.UI
if app == nil {
Expand All @@ -54,11 +74,11 @@ func App(app *tview.Application, rendezvousIP string, config checks.Config, chec

app = tview.NewApplication()
}
appUI = ui.NewUI(app, config, logger)
appUI = ui.NewUI(app, config, logger, rendezvousIP)
controller := ui.NewController(appUI)
engine := checks.NewEngine(controller.GetChan(), config, logger, checkFuncs...)

controller.Init(engine.Size(), rendezvousIP)
controller.Init(engine.Size(), rendezvousIP, interactiveUIMode)
engine.Init()
if err := app.Run(); err != nil {
log.Fatal(err)
Expand Down
21 changes: 15 additions & 6 deletions tools/agent_tui/apptester_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,21 @@ func NewAppTester(t *testing.T, debug ...bool) *AppTester {

// Starts a new Agent TUI in background
func (a *AppTester) Start(config checks.Config) *AppTester {
go App(a.app, "192.168.111.80", config, checks.CheckFunctions{
checks.CheckTypeReleaseImageHostDNS: a.wrapper,
checks.CheckTypeReleaseImageHostPing: a.wrapper,
checks.CheckTypeReleaseImageHttp: a.wrapper,
checks.CheckTypeReleaseImagePull: a.wrapper,
})
ctx := AppContext{
App: a.app,
RendezvousIP: "192.168.111.80",
InteractiveUIMode: false,
Config: config,
CheckFuncs: []checks.CheckFunctions{
{
checks.CheckTypeReleaseImageHostDNS: a.wrapper,
checks.CheckTypeReleaseImageHostPing: a.wrapper,
checks.CheckTypeReleaseImageHttp: a.wrapper,
checks.CheckTypeReleaseImagePull: a.wrapper,
},
},
}
go App(ctx)
return a
}

Expand Down
23 changes: 19 additions & 4 deletions tools/agent_tui/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

const (
RENDEZVOUS_IP_TEMPLATE_VALUE = "{{.RendezvousIP}}"
INTERACTIVE_UI_SENTINEL_PATH = "/etc/assisted/interactive-ui"
)

func main() {
Expand All @@ -30,11 +31,18 @@ func main() {
fmt.Printf("AGENT_TUI_LOG_PATH is unspecified, logging to: %v\n", logPath)
}
rendezvousIP := getRendezvousIP()
config := checks.Config{
ReleaseImageURL: releaseImage,
LogPath: logPath,
interactiveUIMode := IsInteractiveUIEnabled()

ctx := agent_tui.AppContext{
App: nil,
RendezvousIP: rendezvousIP,
InteractiveUIMode: interactiveUIMode,
Config: checks.Config{
ReleaseImageURL: releaseImage,
LogPath: logPath,
},
}
agent_tui.App(nil, rendezvousIP, config)
agent_tui.App(ctx)
}

// getRendezvousIP reads NODE_ZERO_IP from /etc/assisted/rendezvous-host.env.
Expand All @@ -50,3 +58,10 @@ func getRendezvousIP() (nodeZeroIP string) {

return nodeZeroIP
}

// IsInteractiveUIEnabled checks if the interactive UI sentinel file exists.
// Returns true if /etc/assisted/interactive-ui exists, false otherwise.
func IsInteractiveUIEnabled() bool {
_, err := os.Stat(INTERACTIVE_UI_SENTINEL_PATH)
return err == nil
}
142 changes: 141 additions & 1 deletion tools/agent_tui/ui/check_page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestCheckPageNavigation(t *testing.T) {
}

logger := logrus.New()
ui := NewUI(tview.NewApplication(), config, logger)
ui := NewUI(tview.NewApplication(), config, logger, "")

// There are two buttons that the user can navigate between
// <Configure Network> and <Quit>
Expand All @@ -38,8 +38,148 @@ func TestCheckPageNavigation(t *testing.T) {
assert.Equal(t, 0, ui.focusedItem)
}

func TestRendezvousIPPageNavigation(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
ui := NewUI(tview.NewApplication(), config, logger, "")

// Focus on rendezvous IP page
ui.setFocusToRendezvousIP()

// There are four focusable items on the rendezvous IP page:
// 0: Input field (Rendezvous IP)
// 1: Save rendezvous IP button
// 2: This is the rendezvous node button
// 3: Configure Network button
assert.Equal(t, 0, ui.focusedItem)

// Test TAB navigation (forward)
applyKeyToRendezvousIPPage(ui, tcell.KeyTab, 1)
assert.Equal(t, 1, ui.focusedItem) // Save button
applyKeyToRendezvousIPPage(ui, tcell.KeyTab, 1)
assert.Equal(t, 2, ui.focusedItem) // This is rendezvous node button
applyKeyToRendezvousIPPage(ui, tcell.KeyTab, 1)
assert.Equal(t, 3, ui.focusedItem) // Configure Network button
applyKeyToRendezvousIPPage(ui, tcell.KeyTab, 1)
assert.Equal(t, 0, ui.focusedItem) // Back to input field

// Test BACKTAB navigation (backward)
applyKeyToRendezvousIPPage(ui, tcell.KeyBacktab, 1)
assert.Equal(t, 3, ui.focusedItem) // Configure Network button
applyKeyToRendezvousIPPage(ui, tcell.KeyBacktab, 1)
assert.Equal(t, 2, ui.focusedItem) // This is rendezvous node button
applyKeyToRendezvousIPPage(ui, tcell.KeyBacktab, 1)
assert.Equal(t, 1, ui.focusedItem) // Save button
applyKeyToRendezvousIPPage(ui, tcell.KeyBacktab, 1)
assert.Equal(t, 0, ui.focusedItem) // Input field
}

func TestRendezvousIPPageNavigationWithPrefilled(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
prefilledIP := "192.168.111.80"
ui := NewUI(tview.NewApplication(), config, logger, prefilledIP)

// Verify the input field has the prefilled IP
inputField := ui.rendezvousIPForm.GetFormItemByLabel(FIELD_ENTER_RENDEZVOUS_IP)
assert.NotNil(t, inputField)

// Verify initial rendezvous IP is set
assert.Equal(t, prefilledIP, ui.initialRendezvousIP)
}

func TestInteractiveUIModeWithPrefilledIP(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
prefilledIP := "192.168.111.80"
ui := NewUI(tview.NewApplication(), config, logger, prefilledIP)
controller := NewController(ui)

// Initialize with interactive mode and prefilled IP
controller.Init(1, prefilledIP, true)

// Verify timeout modal is active
assert.True(t, ui.IsRendezvousIPTimeoutActive())
}

func TestInteractiveUIModeWithoutPrefilledIP(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
ui := NewUI(tview.NewApplication(), config, logger, "")
controller := NewController(ui)

// Initialize with interactive mode but no prefilled IP
controller.Init(1, "", true)

// Verify timeout modal is NOT active
assert.False(t, ui.IsRendezvousIPTimeoutActive())
}

func TestNonInteractiveUIMode(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
ui := NewUI(tview.NewApplication(), config, logger, "")
controller := NewController(ui)

// Initialize with non-interactive mode
controller.Init(1, "", false)

// Verify splash screen is shown and timeout modal is NOT active
assert.False(t, ui.IsRendezvousIPTimeoutActive())
}

func TestTimeoutModalCancellation(t *testing.T) {
config := checks.Config{
ReleaseImageURL: "",
LogPath: "/tmp/agent-tui.log",
}

logger := logrus.New()
prefilledIP := "192.168.111.80"
ui := NewUI(tview.NewApplication(), config, logger, prefilledIP)

// Show timeout modal
ui.ShowRendezvousIPTimeoutDialog(prefilledIP)
assert.True(t, ui.IsRendezvousIPTimeoutActive())

// Cancel the timeout modal
ui.cancelRendezvousIPTimeout()
assert.False(t, ui.IsRendezvousIPTimeoutActive())
}

func applyKeyToChecks(u *UI, key tcell.Key, numKeyPresses int) {
for i := 0; i < numKeyPresses; i++ {
u.mainFlex.InputHandler()(tcell.NewEventKey(key, 0, tcell.ModNone), func(p tview.Primitive) {})
}
}

func applyKeyToRendezvousIPPage(u *UI, key tcell.Key, numKeyPresses int) {
// Get the rendezvous IP page from pages
page, _ := u.pages.GetFrontPage()
if page == PAGE_RENDEZVOUS_IP {
for i := 0; i < numKeyPresses; i++ {
// Apply key event through the main flex which has the input capture logic
u.rendezvousIPMainFlex.InputHandler()(tcell.NewEventKey(key, 0, tcell.ModNone), func(p tview.Primitive) {})
}
}
}
12 changes: 6 additions & 6 deletions tools/agent_tui/ui/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ func (c *Controller) receivedPrimaryCheck(numChecks int) bool {
return found
}

func (c *Controller) Init(numChecks int, rendezvousIP string) {
c.ui.ShowSplashScreen()

if rendezvousIP == "" {
c.ui.setFocusToRendezvousIP()
func (c *Controller) Init(numChecks int, rendezvousIP string, interactiveUIMode bool) {
if interactiveUIMode {
// NoRegistry (OVE) ISO flow - show rendezvous IP page
c.ui.ShowRendezvousIPPage(rendezvousIP)
} else {
c.ui.setFocusToChecks()
// Agent ISO flow - show splash screen while collecting initial check results
c.ui.ShowSplashScreen()
}

go func() {
Expand Down
18 changes: 13 additions & 5 deletions tools/agent_tui/ui/netstate_treeview.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ func getRouteTree(route net.Route) *tview.TreeNode {
return root
}

func (u *UI) ModalTreeView(netState net.NetState) (tview.Primitive, error) {
// ModalTreeView creates a centered modal dialog containing a network state tree view.
// The doneFunc callback specifies where to navigate after the user exits the tree view.
func (u *UI) ModalTreeView(netState net.NetState, doneFunc func()) (tview.Primitive, error) {
if u.pages == nil {
return nil, fmt.Errorf("can't make a NetState treeView page for nil pages")
}

treeView, err := u.TreeView(netState)
treeView, err := u.TreeView(netState, doneFunc)
if err != nil {
return nil, err
}
Expand All @@ -65,7 +67,11 @@ func (u *UI) ModalTreeView(netState net.NetState) (tview.Primitive, error) {
AddItem(nil, 0, 1, false), err
}

func (u *UI) TreeView(netState net.NetState) (*tview.TreeView, error) {
// TreeView creates a tree view for displaying network state information.
// The doneFunc callback is called when the user exits the tree view (via 'q' key or ESC).
// This allows the caller to specify where to navigate after exiting, ensuring the user
// returns to the correct page (e.g., checks page vs rendezvous IP page).
func (u *UI) TreeView(netState net.NetState, doneFunc func()) (*tview.TreeView, error) {
if u.pages == nil {
return nil, fmt.Errorf("can't make a NetState treeView page for nil pages")
}
Expand All @@ -76,7 +82,8 @@ func (u *UI) TreeView(netState net.NetState) (*tview.TreeView, error) {
SetCurrentNode(root).SetDoneFunc(
func(key tcell.Key) {
u.pages.RemovePage("netstate")
u.setFocusToChecks()
// Navigate back to the caller's page (checks or rendezvous IP)
doneFunc()
})

tree.SetTitle("Network Status").
Expand All @@ -89,7 +96,8 @@ func (u *UI) TreeView(netState net.NetState) (*tview.TreeView, error) {
func(event *tcell.EventKey) *tcell.EventKey {
if event.Rune() == 'q' {
u.pages.RemovePage("netstate")
u.setFocusToChecks()
// Navigate back to the caller's page (checks or rendezvous IP)
doneFunc()
}
return event
})
Expand Down
6 changes: 3 additions & 3 deletions tools/agent_tui/ui/nmtui.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"github.com/rivo/tview"
)

func (u *UI) ShowNMTUI() error {
func (u *UI) ShowNMTUI(doneFunc func()) error {
u.nmtuiActive.Store(true)
defer u.nmtuiActive.Store(false)

Expand All @@ -38,7 +38,7 @@ func (u *UI) ShowNMTUI() error {
return err
}

netStatePage, err := u.ModalTreeView(netState)
netStatePage, err := u.ModalTreeView(netState, doneFunc)
if err != nil {
return err
}
Expand All @@ -48,7 +48,7 @@ func (u *UI) ShowNMTUI() error {
}

func (u *UI) showNMTUIWithErrorDialog(doneFunc func()) {
if err := u.ShowNMTUI(); err != nil {
if err := u.ShowNMTUI(doneFunc); err != nil {
u.logger.Infof("error from ShowNMTUI: %v", err)
errorDialog := tview.NewModal().
SetBackgroundColor(newt.ColorGray).
Expand Down
Loading