Skip to content
Open
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
7 changes: 4 additions & 3 deletions tools/agent_tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/sirupsen/logrus"
)

func App(app *tview.Application, rendezvousIP string, config checks.Config, checkFuncs ...checks.CheckFunctions) {
func App(app *tview.Application, rendezvousIP string, interactiveUIMode bool, config checks.Config, checkFuncs ...checks.CheckFunctions) {

if err := prepareConfig(&config); err != nil {
log.Fatal(err)
Expand All @@ -34,6 +34,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 +55,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
2 changes: 1 addition & 1 deletion tools/agent_tui/apptester_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ 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{
go App(a.app, "192.168.111.80", false, config, checks.CheckFunctions{
checks.CheckTypeReleaseImageHostDNS: a.wrapper,
checks.CheckTypeReleaseImageHostPing: a.wrapper,
checks.CheckTypeReleaseImageHttp: a.wrapper,
Expand Down
22 changes: 16 additions & 6 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 @@ -29,24 +30,33 @@ func main() {
logPath = "/tmp/agent_tui.log"
fmt.Printf("AGENT_TUI_LOG_PATH is unspecified, logging to: %v\n", logPath)
}
rendezvousIP := getRendezvousIP()
rendezvousIP, interactiveUIMode := getRendezvousIP()
config := checks.Config{
ReleaseImageURL: releaseImage,
LogPath: logPath,
}
agent_tui.App(nil, rendezvousIP, config)
agent_tui.App(nil, rendezvousIP, interactiveUIMode, config)
}

// getRendezvousIP reads NODE_ZERO_IP from /etc/assisted/rendezvous-host.env.
func getRendezvousIP() (nodeZeroIP string) {
// getRendezvousIP reads NODE_ZERO_IP from /etc/assisted/rendezvous-host.env
// and checks if the interactive UI sentinel file exists.
// Returns (rendezvousIP, interactiveUIMode) where:
// - rendezvousIP: the IP address from rendezvous-host.env, or empty string
// - interactiveUIMode: true if /etc/assisted/interactive-ui exists
func getRendezvousIP() (nodeZeroIP string, interactiveUIMode bool) {
// Check if the interactive UI sentinel file exists
_, err := os.Stat(INTERACTIVE_UI_SENTINEL_PATH)
interactiveUIMode = (err == nil)

// Read rendezvous IP from the standard location
envMap, err := godotenv.Read(ui.RENDEZVOUS_HOST_ENV_PATH)
if err != nil {
return ""
return "", interactiveUIMode
}
nodeZeroIP = envMap["NODE_ZERO_IP"]
if nodeZeroIP == RENDEZVOUS_IP_TEMPLATE_VALUE {
nodeZeroIP = ""
}

return nodeZeroIP
return nodeZeroIP, interactiveUIMode
}
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) {})
}
}
}
18 changes: 14 additions & 4 deletions tools/agent_tui/ui/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,22 @@ func (c *Controller) receivedPrimaryCheck(numChecks int) bool {
return found
}

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

if rendezvousIP == "" {
func (c *Controller) Init(numChecks int, rendezvousIP string, interactiveUIMode bool) {
if interactiveUIMode {
// NoRegistry (OVE) ISO flow
c.ui.setFocusToRendezvousIP()

if rendezvousIP != "" {
// Show timeout modal with the prefilled rendezvous IP
c.ui.logger.Infof("Interactive UI mode with prefilled IP: %s", rendezvousIP)
c.ui.ShowRendezvousIPTimeoutDialog(rendezvousIP)
} else {
// Do not show timeout modal
c.ui.logger.Infof("Interactive UI mode with empty IP form")
}
} else {
// Agent ISO flow
c.ui.ShowSplashScreen()
c.ui.setFocusToChecks()
}

Expand Down
40 changes: 32 additions & 8 deletions tools/agent_tui/ui/rendezvous_ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (u *UI) createRendezvousIPPage(config checks.Config) {
rendezvousTextFlex := u.createTextFlex(rendezvousIPFormDescription)
rendezvousTextNumRows := 8

u.rendezvousIPForm.AddInputField(FIELD_ENTER_RENDEZVOUS_IP, "", 55, nil, nil)
u.rendezvousIPForm.AddInputField(FIELD_ENTER_RENDEZVOUS_IP, u.initialRendezvousIP, 55, nil, nil)
u.rendezvousIPForm.SetFieldTextColor(newt.ColorGray)

u.rendezvousIPForm.AddButton(SAVE_RENDEZVOUS_IP_BUTTON, func() {
Expand Down Expand Up @@ -84,31 +84,55 @@ func (u *UI) createRendezvousIPPage(config checks.Config) {
u.selectIPForm.SetButtonStyle(tcell.StyleDefault.Background(newt.ColorGray).
Foreground(newt.ColorBlack))

mainFlex := tview.NewFlex().
// Add a seperator
separator := tview.NewTextView()
separator.SetText("────────────────────────────────────────────────────────────────────────────")
separator.SetTextAlign(tview.AlignCenter)
separator.SetTextColor(newt.ColorBlack)
separator.SetBackgroundColor(newt.ColorGray)

// Add 'Configure Network' button at bottom right
u.configureNetworkForm = tview.NewForm()
u.configureNetworkForm.SetBorder(false)
u.configureNetworkForm.SetButtonsAlign(tview.AlignRight)
u.configureNetworkForm.AddButton(RENDEZVOUS_CONFIGURE_NETWORK_BUTTON, func() {
u.showNMTUIWithErrorDialog(func() {
u.setFocusToRendezvousIP()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After quitting from nmtui, the UI redirects to a page showing the release image checks. It should go back to the rendezvous IP form.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen.Recording.2026-02-19.at.10.16.40.AM.mov

})
})
u.configureNetworkForm.SetButtonActivatedStyle(tcell.StyleDefault.Background(newt.ColorRed).
Foreground(newt.ColorGray))
u.configureNetworkForm.SetButtonStyle(tcell.StyleDefault.Background(newt.ColorGray).
Foreground(newt.ColorBlack))

u.rendezvousIPMainFlex = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(rendezvousTextFlex, rendezvousTextNumRows, 0, false).
AddItem(u.rendezvousIPForm, 5, 0, false).
AddItem(selectTextFlex, selectTextNumRows, 0, false).
AddItem(u.selectIPForm, 4, 0, false)
mainFlex.SetTitle(" Rendezvous node setup ").
AddItem(u.selectIPForm, 3, 0, false).
AddItem(separator, 1, 0, false).
AddItem(u.configureNetworkForm, 3, 0, false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bottom border is missing.

Image

u.rendezvousIPMainFlex.SetTitle(" Rendezvous node setup ").
SetTitleColor(newt.ColorRed).
SetBorder(true)

mainFlexHeight := 5 + 3 + 1 + 3 // form + selectIPForm + separator + configureNetworkForm
innerFlex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(mainFlex, mainFlexHeight+1+rendezvousTextNumRows+selectTextNumRows, 0, false).
AddItem(u.rendezvousIPMainFlex, mainFlexHeight+1+rendezvousTextNumRows+selectTextNumRows, 0, false).
AddItem(nil, 0, 1, false)

// Allow the user to cycle the focus only over the configured items
mainFlex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
u.rendezvousIPMainFlex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTab, tcell.KeyUp:
case tcell.KeyTab, tcell.KeyDown:
u.focusedItem++
if u.focusedItem > len(u.focusableItems)-1 {
u.focusedItem = 0
}

case tcell.KeyBacktab, tcell.KeyDown:
case tcell.KeyBacktab, tcell.KeyUp:
u.focusedItem--
if u.focusedItem < 0 {
u.focusedItem = len(u.focusableItems) - 1
Expand Down
4 changes: 2 additions & 2 deletions tools/agent_tui/ui/rendezvous_ip_save_success_modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (

const (
PAGE_RENDEZVOUS_IP_SAVE_SUCCESS string = "rendezvousIPSaveSuccessPage"
CONTINUE_BUTTON = "<Continue>"
BACK_BUTTON = "<Back>"
CONTINUE_BUTTON string = "<Continue>"
BACK_BUTTON string = "<Back>"

SUCCESS_TEXT_FORMAT = "Successfully saved %s as the rendezvous node IP. "
OTHER_NODES_TEXT_FORMAT = "Enter %s as the rendezvous node IP on the other nodes that will form the cluster."
Expand Down
8 changes: 1 addition & 7 deletions tools/agent_tui/ui/rendezvous_ip_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,20 +135,14 @@ func (u *UI) updateSelectIPList(ipAddresses []string) {
// only add spacer line if there are IP addresses
options = append(options, EMPTY_OPTION)
}
options = append(options, backOption, RENDEZVOUS_CONFIGURE_NETWORK_BUTTON)
options = append(options, backOption)
for _, selected := range options {
u.selectIPList.AddItem(selected, "", rune(0), func() {
switch selected {
case EMPTY_OPTION:
// spacing between IP addresses and buttons
case backOption:
u.setFocusToRendezvousIP()
case RENDEZVOUS_CONFIGURE_NETWORK_BUTTON:
u.showNMTUIWithErrorDialog(func() {
u.refreshSelectIPList()
u.setFocusToSelectIP()
})
u.setFocusToSelectIP()
default:
err := u.saveRendezvousIPAddress(selected)
if err != nil {
Expand Down
Loading